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:
@@ -42,8 +42,8 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
|
||||
|
||||
g := &SourceGroup{
|
||||
ID: ulid.Make().String(), HostID: hostID, Name: "etc",
|
||||
PreHook: []byte("ENC-PRE"),
|
||||
PostHook: []byte("ENC-POST"),
|
||||
PreHook: "ENC-PRE",
|
||||
PostHook: "ENC-POST",
|
||||
}
|
||||
if err := st.CreateSourceGroup(context.Background(), g); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
@@ -52,16 +52,16 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(got.PreHook) != "ENC-PRE" {
|
||||
if got.PreHook != "ENC-PRE" {
|
||||
t.Fatalf("PreHook: got %q, want ENC-PRE", got.PreHook)
|
||||
}
|
||||
if string(got.PostHook) != "ENC-POST" {
|
||||
if got.PostHook != "ENC-POST" {
|
||||
t.Fatalf("PostHook: got %q, want ENC-POST", got.PostHook)
|
||||
}
|
||||
|
||||
// Update: clear PreHook, change PostHook.
|
||||
got.PreHook = nil
|
||||
got.PostHook = []byte("ENC-POST-2")
|
||||
got.PreHook = ""
|
||||
got.PostHook = "ENC-POST-2"
|
||||
if err := st.UpdateSourceGroup(context.Background(), got); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
@@ -69,10 +69,10 @@ func TestSourceGroupHooksRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.PreHook != nil {
|
||||
t.Fatalf("PreHook: want nil after clear, got %q", got.PreHook)
|
||||
if got.PreHook != "" {
|
||||
t.Fatalf("PreHook: want empty after clear, got %q", got.PreHook)
|
||||
}
|
||||
if string(got.PostHook) != "ENC-POST-2" {
|
||||
if got.PostHook != "ENC-POST-2" {
|
||||
t.Fatalf("PostHook: got %q, want ENC-POST-2", got.PostHook)
|
||||
}
|
||||
}
|
||||
@@ -82,25 +82,25 @@ func TestHostHookDefaultsRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
hostID := makeHostInStore(t, st, "host-hooks-host")
|
||||
|
||||
if err := st.SetHostHooks(context.Background(), hostID, []byte("PRE"), []byte("POST")); err != nil {
|
||||
if err := st.SetHostHooks(context.Background(), hostID, "PRE", "POST"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
h, err := st.GetHost(context.Background(), hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(h.PreHookDefault) != "PRE" || string(h.PostHookDefault) != "POST" {
|
||||
if h.PreHookDefault != "PRE" || h.PostHookDefault != "POST" {
|
||||
t.Fatalf("after set: pre=%q post=%q", h.PreHookDefault, h.PostHookDefault)
|
||||
}
|
||||
// Clear by passing nil.
|
||||
if err := st.SetHostHooks(context.Background(), hostID, nil, nil); err != nil {
|
||||
// Clear by passing empty strings.
|
||||
if err := st.SetHostHooks(context.Background(), hostID, "", ""); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
h, err = st.GetHost(context.Background(), hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if h.PreHookDefault != nil || h.PostHookDefault != nil {
|
||||
t.Fatalf("after clear: pre=%v post=%v (want nil)", h.PreHookDefault, h.PostHookDefault)
|
||||
if h.PreHookDefault != "" || h.PostHookDefault != "" {
|
||||
t.Fatalf("after clear: pre=%q post=%q (want empty)", h.PreHookDefault, h.PostHookDefault)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -158,7 +158,7 @@ func scanHostRow(s hostScanner) (*Host, error) {
|
||||
enrolled string
|
||||
tags string
|
||||
bwUp, bwDown sql.NullInt64
|
||||
preHook, postHook []byte
|
||||
preHook, postHook sql.NullString
|
||||
)
|
||||
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
|
||||
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
|
||||
@@ -215,18 +215,22 @@ func scanHostRow(s hostScanner) (*Host, error) {
|
||||
v := int(bwDown.Int64)
|
||||
h.BandwidthDownKBps = &v
|
||||
}
|
||||
h.PreHookDefault = preHook
|
||||
h.PostHookDefault = postHook
|
||||
if preHook.Valid {
|
||||
h.PreHookDefault = preHook.String
|
||||
}
|
||||
if postHook.Valid {
|
||||
h.PostHookDefault = postHook.String
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
// SetHostHooks replaces the host-wide pre/post hook defaults. Pass
|
||||
// nil/empty to clear that hook. Stored verbatim — caller is expected
|
||||
// to encrypt the bytes before they reach this layer.
|
||||
func (s *Store) SetHostHooks(ctx context.Context, hostID string, pre, post []byte) error {
|
||||
// the empty string to clear that hook. Stored verbatim — caller is
|
||||
// expected to encrypt before they reach this layer.
|
||||
func (s *Store) SetHostHooks(ctx context.Context, hostID string, pre, post string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE hosts SET pre_hook_default = ?, post_hook_default = ? WHERE id = ?`,
|
||||
nullableBytes(pre), nullableBytes(post), hostID)
|
||||
nullableString(pre), nullableString(post), hostID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set host hooks: %w", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+12
-11
@@ -67,11 +67,12 @@ type Host struct {
|
||||
BandwidthUpKBps *int
|
||||
BandwidthDownKBps *int
|
||||
|
||||
// PreHookDefault / PostHookDefault are AEAD-encrypted host-wide
|
||||
// hook bodies. Per source group hooks (SourceGroup.PreHook /
|
||||
// PostHook) override these when set. nil = no default configured.
|
||||
PreHookDefault []byte
|
||||
PostHookDefault []byte
|
||||
// PreHookDefault / PostHookDefault are AEAD ciphertext (string
|
||||
// blob produced by crypto.AEAD.Encrypt). Per source group hooks
|
||||
// (SourceGroup.PreHook / PostHook) override these when set.
|
||||
// Empty = no default configured.
|
||||
PreHookDefault string
|
||||
PostHookDefault string
|
||||
}
|
||||
|
||||
// Schedule is now intentionally slim: cron + which groups + enabled.
|
||||
@@ -113,12 +114,12 @@ type SourceGroup struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// PreHook / PostHook are AEAD-encrypted shell snippets (raw blob).
|
||||
// nil means "no hook configured." Encryption/decryption happens at
|
||||
// the HTTP layer (where AEAD lives); the store layer just persists
|
||||
// the bytes verbatim.
|
||||
PreHook []byte
|
||||
PostHook []byte
|
||||
// PreHook / PostHook are AEAD ciphertext (string blob produced by
|
||||
// crypto.AEAD.Encrypt). Empty means "no hook configured."
|
||||
// Encryption/decryption happens at the HTTP layer (where AEAD
|
||||
// lives); the store layer just persists the bytes verbatim.
|
||||
PreHook string
|
||||
PostHook string
|
||||
}
|
||||
|
||||
// RetentionPolicy is the typed view of `restic forget --keep-*`.
|
||||
|
||||
Reference in New Issue
Block a user