agent+server: P2R-11 pre/post hook execution for backup jobs

Agent: new runner.BackupHooks struct + runHook helper invoked via
/bin/sh -c (cmd.exe /C on Windows). pre_hook non-zero exit aborts
the backup; post_hook always runs with RM_JOB_STATUS=succeeded|failed
in env. Output streamed as 'hook(<phase>): …' log.stream lines.
Hooks only run for kind=backup (other kinds skip both phases).

Server: resolveBackupHooks resolves group → host default → empty,
decrypts via crypto.AEAD with per-slot ad bytes, plumbs plaintext
into CommandRunPayload for both schedule.fire and per-group
Run-now dispatch sites. Decrypt failures degrade silently to no
hook so a malformed blob can't poison every backup.
This commit is contained in:
2026-05-04 10:57:28 +01:00
parent 18b0bf976d
commit 7b1990cf11
11 changed files with 379 additions and 52 deletions
+9 -15
View File
@@ -52,7 +52,7 @@ func (st *Store) CreateSourceGroup(ctx context.Context, g *SourceGroup) error {
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
nullableBytes(g.PreHook), nullableBytes(g.PostHook),
nullableString(g.PreHook), nullableString(g.PostHook),
); err != nil {
return fmt.Errorf("store: create source group: %w", err)
}
@@ -96,7 +96,7 @@ func (st *Store) UpdateSourceGroup(ctx context.Context, g *SourceGroup) error {
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano),
nullableBytes(g.PreHook), nullableBytes(g.PostHook),
nullableString(g.PreHook), nullableString(g.PostHook),
g.ID, g.HostID,
)
if err != nil {
@@ -226,7 +226,7 @@ func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
includes, excludes, retention string
conflict sql.NullString
createdAt, updatedAt string
preHook, postHook []byte
preHook, postHook sql.NullString
)
err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention,
@@ -235,8 +235,12 @@ func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
if err != nil {
return nil, err
}
out.PreHook = preHook
out.PostHook = postHook
if preHook.Valid {
out.PreHook = preHook.String
}
if postHook.Valid {
out.PostHook = postHook.String
}
if includes != "" {
_ = json.Unmarshal([]byte(includes), &out.Includes)
}
@@ -264,13 +268,3 @@ func nullableString(s string) any {
}
return s
}
// nullableBytes returns nil for an empty/nil slice so SQL stores it
// as NULL rather than an empty BLOB. The agent treats both the same
// (no hook), but NULL is the canonical "absent" form on disk.
func nullableBytes(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}