Skip to content

Commit 35df924

Browse files
pmaxhoganclaude
andauthored
feat(core): pre/post backup shell hooks (#16)
Run optional user-configured shell commands around each backup cycle (V2 pre/post backup hooks, DESIGN s17). Core: a new I/O-free `CommandRunner` seam (`hooks` module) with an inert `NoopCommandRunner` default and a `with_command_runner` builder (mirrors `with_vss`, so none of the existing constructors churn). The orchestrator runs the pre-hook before the per-cycle source loop - a non-zero / timed out / unspawnable pre-hook ABORTS that cycle's backup - and the post-hook after the loop with `DRIVEN_RESULT` = ok/error. Each run is recorded as a `hook.<kind>` activity row. Hook commands + a kill timeout live in `OrchestratorConfig`. Pre/post success/abort and env passing are tested with a fake runner. App: a real `TokioCommandRunner` (sh -c / cmd /C, env, kill-on-timeout) wired in `assembly`, with unix tests for exit code / env / timeout. Settings: threaded through the SPEC s22 `global` group (DTO, patch with null-clears semantics, snake_case storage with serde(default), default, and `load_orchestrator_config`). UI: a "Backup hooks" Settings section (before/after command + timeout), wired through the store with TS types, i18n, and a component test. Claude-Session: https://claude.ai/code/session_01WvXMHHbYGddVPpmQR2XmK1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7062037 commit 35df924

13 files changed

Lines changed: 723 additions & 0 deletions

File tree

crates/driven-core/src/hooks.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Pre/post backup hook seam (V2 pre/post backup shell hooks, DESIGN s17).
2+
//!
3+
//! `driven-core` stays free of direct process I/O, so the orchestrator runs a
4+
//! user-configured shell command through this injected [`CommandRunner`]
5+
//! trait. The app wires a real tokio-process implementation; tests inject a
6+
//! fake. The default [`NoopCommandRunner`] reports success without running
7+
//! anything, so the gate is inert until a real runner is attached.
8+
9+
use std::time::Duration;
10+
11+
use async_trait::async_trait;
12+
13+
/// Which hook is being run, for env (`DRIVEN_HOOK`) and the activity row.
14+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15+
pub enum HookKind {
16+
/// Runs before a backup cycle touches any source.
17+
Pre,
18+
/// Runs after a backup cycle's source loop, regardless of outcome.
19+
Post,
20+
}
21+
22+
impl HookKind {
23+
/// The lowercase discriminant used in env vars + the `hook.<kind>`
24+
/// activity event type.
25+
pub fn as_str(self) -> &'static str {
26+
match self {
27+
HookKind::Pre => "pre",
28+
HookKind::Post => "post",
29+
}
30+
}
31+
}
32+
33+
/// The outcome of running a hook command.
34+
#[derive(Debug, Clone, PartialEq, Eq)]
35+
pub struct HookOutcome {
36+
/// The process exit code when it exited normally; `None` when it was
37+
/// killed (timeout) or never produced an exit status.
38+
pub exit_code: Option<i32>,
39+
/// True when the command was killed for exceeding its timeout.
40+
pub timed_out: bool,
41+
/// A spawn / wait error (e.g. the shell or binary was not found) when the
42+
/// runner could not run the command at all; `None` otherwise.
43+
pub spawn_error: Option<String>,
44+
}
45+
46+
impl HookOutcome {
47+
/// A clean success: exit 0, not timed out, spawned fine.
48+
pub fn success() -> Self {
49+
Self {
50+
exit_code: Some(0),
51+
timed_out: false,
52+
spawn_error: None,
53+
}
54+
}
55+
56+
/// True only when the command ran to completion with a zero exit code.
57+
pub fn succeeded(&self) -> bool {
58+
!self.timed_out && self.spawn_error.is_none() && self.exit_code == Some(0)
59+
}
60+
61+
/// A short human description for the activity-log message.
62+
pub fn describe(&self) -> String {
63+
if let Some(err) = &self.spawn_error {
64+
format!("failed to run ({err})")
65+
} else if self.timed_out {
66+
"timed out".to_string()
67+
} else {
68+
match self.exit_code {
69+
Some(0) => "ok".to_string(),
70+
Some(code) => format!("exited with code {code}"),
71+
None => "killed".to_string(),
72+
}
73+
}
74+
}
75+
}
76+
77+
/// Runs a user-configured shell command (the pre/post backup hooks).
78+
///
79+
/// Implementations receive the raw command string, a set of `(key, value)`
80+
/// environment variables to pass to it, and a timeout after which the command
81+
/// must be killed (returning `timed_out: true`). They must never panic or
82+
/// propagate an error: a command that cannot be spawned returns a
83+
/// [`HookOutcome`] with `spawn_error` set.
84+
#[async_trait]
85+
pub trait CommandRunner: Send + Sync {
86+
/// Run `command`, passing `env`, killing it after `timeout`.
87+
async fn run(&self, command: &str, env: &[(String, String)], timeout: Duration) -> HookOutcome;
88+
}
89+
90+
/// The default runner: reports success without running anything. Used when no
91+
/// real runner is injected (the orchestrator's `new` default), so a configured
92+
/// hook is simply inert until the app wires a real [`CommandRunner`].
93+
#[derive(Debug, Default)]
94+
pub struct NoopCommandRunner;
95+
96+
#[async_trait]
97+
impl CommandRunner for NoopCommandRunner {
98+
async fn run(
99+
&self,
100+
_command: &str,
101+
_env: &[(String, String)],
102+
_timeout: Duration,
103+
) -> HookOutcome {
104+
HookOutcome::success()
105+
}
106+
}
107+
108+
#[cfg(test)]
109+
mod tests {
110+
use super::*;
111+
112+
#[test]
113+
fn succeeded_only_on_clean_zero_exit() {
114+
assert!(HookOutcome::success().succeeded());
115+
assert!(!HookOutcome {
116+
exit_code: Some(1),
117+
timed_out: false,
118+
spawn_error: None,
119+
}
120+
.succeeded());
121+
assert!(!HookOutcome {
122+
exit_code: None,
123+
timed_out: true,
124+
spawn_error: None,
125+
}
126+
.succeeded());
127+
assert!(!HookOutcome {
128+
exit_code: None,
129+
timed_out: false,
130+
spawn_error: Some("not found".into()),
131+
}
132+
.succeeded());
133+
}
134+
135+
#[test]
136+
fn describe_is_human_readable() {
137+
assert_eq!(HookOutcome::success().describe(), "ok");
138+
assert_eq!(
139+
HookOutcome {
140+
exit_code: Some(2),
141+
timed_out: false,
142+
spawn_error: None
143+
}
144+
.describe(),
145+
"exited with code 2"
146+
);
147+
assert_eq!(
148+
HookOutcome {
149+
exit_code: None,
150+
timed_out: true,
151+
spawn_error: None
152+
}
153+
.describe(),
154+
"timed out"
155+
);
156+
}
157+
158+
#[tokio::test]
159+
async fn noop_runner_reports_success() {
160+
let r = NoopCommandRunner;
161+
let out = r.run("anything", &[], Duration::from_secs(1)).await;
162+
assert!(out.succeeded());
163+
}
164+
}

crates/driven-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
pub mod crypto_provider;
2323
pub mod exclude;
2424
pub mod executor;
25+
pub mod hooks;
2526
pub mod network;
2627
pub mod orchestrator;
2728
pub mod pacer;

0 commit comments

Comments
 (0)