agent+server: apply host bandwidth caps to restic invocations

P2R-13a. restic.Env gains LimitUploadKBps/LimitDownloadKBps which are
emitted as global --limit-upload/--limit-download flags before the
subcommand on every invocation. Agent dispatcher tracks host-wide
caps received via config.update; server pushes them on hello and
after PUT /api/hosts/{id}/bandwidth.

Also extends api.CommandRunPayload with optional per-job overrides
(BandwidthUpKBps/Down + PreHook/PostHook); the override consumers
land in T2/T6.
This commit is contained in:
2026-05-04 10:38:34 +01:00
parent 95ab3f4d16
commit cdf88c6dc3
8 changed files with 246 additions and 35 deletions
+38 -23
View File
@@ -47,6 +47,37 @@ type Env struct {
RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password
ExtraEnv map[string]string // any other RESTIC_* / passthrough
WorkDir string // CWD; default = current
// Bandwidth caps in KB/s. <=0 means "no cap" (omit the flag).
// Emitted as restic global flags --limit-upload / --limit-download
// before the subcommand on every invocation.
LimitUploadKBps int
LimitDownloadKBps int
}
// globalArgs returns restic's pre-subcommand global flags derived
// from the Env. Currently just bandwidth caps.
func (e Env) globalArgs() []string {
var out []string
if e.LimitUploadKBps > 0 {
out = append(out, "--limit-upload", fmt.Sprintf("%d", e.LimitUploadKBps))
}
if e.LimitDownloadKBps > 0 {
out = append(out, "--limit-download", fmt.Sprintf("%d", e.LimitDownloadKBps))
}
return out
}
// resticCmd builds an exec.Cmd with bandwidth-limit globals prefixed
// before the supplied subcommand args. Centralizing this so every
// command (backup/forget/prune/check/unlock/init/stats) honors
// the caps without each call site having to remember.
func (e Env) resticCmd(ctx context.Context, sub ...string) *exec.Cmd {
args := append(e.globalArgs(), sub...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
return cmd
}
// EventKind enumerates what we care about in restic's --json output
@@ -110,9 +141,7 @@ func (e Env) RunBackup(ctx context.Context, paths, excludes, tags []string, hand
}
args = append(args, paths...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
cmd := e.resticCmd(ctx, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -215,9 +244,7 @@ func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHan
}
args := []string{"forget", "--json", "--tag", g.Tag}
args = append(args, g.Policy.args()...)
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
cmd := e.resticCmd(ctx, args...)
if err := runWithPump(cmd, handle); err != nil {
return err
}
@@ -232,9 +259,7 @@ func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHan
// <id> at <url>" on success, "config file already exists" on a
// re-init attempt, etc.).
func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
cmd := exec.CommandContext(ctx, e.Bin, "init")
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
cmd := e.resticCmd(ctx, "init")
// Sniff for "config file already exists" on stderr; if we see it
// we'll treat the non-zero exit as a soft success — running init
@@ -272,10 +297,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
// support that's useful for our purposes). We tee everything to the
// handler so the live log is the operator's progress bar.
func (e Env) RunPrune(ctx context.Context, handle LineHandler) error {
cmd := exec.CommandContext(ctx, e.Bin, "prune")
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
return runWithPump(cmd, handle)
return runWithPump(e.resticCmd(ctx, "prune"), handle)
}
// runWithPump starts the configured cmd, fans stdout+stderr into
@@ -313,10 +335,7 @@ func runWithPump(cmd *exec.Cmd, handle LineHandler) error {
// RunUnlock executes `restic unlock`. Returns nil on a clean exit.
func (e Env) RunUnlock(ctx context.Context, handle LineHandler) error {
cmd := exec.CommandContext(ctx, e.Bin, "unlock")
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
return runWithPump(cmd, handle)
return runWithPump(e.resticCmd(ctx, "unlock"), handle)
}
// RepoStats mirrors `restic stats --json --mode raw-data` output.
@@ -333,9 +352,7 @@ type RepoStats struct {
// caller can still log it. Returns an error if no JSON-shaped line
// arrived on stdout.
func (e Env) RunStats(ctx context.Context, handle LineHandler) (*RepoStats, error) {
cmd := exec.CommandContext(ctx, e.Bin, "stats", "--json", "--mode", "raw-data")
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
cmd := e.resticCmd(ctx, "stats", "--json", "--mode", "raw-data")
var out *RepoStats
capture := func(stream, line string, ev any) {
if stream == "stdout" && strings.HasPrefix(line, "{") {
@@ -378,9 +395,7 @@ func (e Env) RunCheck(ctx context.Context, subsetPct int, handle LineHandler) (C
if subsetPct > 0 {
args = append(args, "--read-data-subset", fmt.Sprintf("%d%%", subsetPct))
}
cmd := exec.CommandContext(ctx, e.Bin, args...)
cmd.Env = e.envSlice()
cmd.Dir = e.WorkDir
cmd := e.resticCmd(ctx, args...)
var res CheckResult
sniff := func(stream, line string, ev any) {
+37
View File
@@ -174,6 +174,43 @@ func TestRunStatsErrorsWithoutJSON(t *testing.T) {
}
}
func TestBandwidthLimitFlagsInjected(t *testing.T) {
// Script echoes its argv to stdout. Each variant should produce
// the right --limit-* flags before the subcommand.
cases := []struct {
name string
env Env
want []string
}{
{"both caps", Env{LimitUploadKBps: 1024, LimitDownloadKBps: 512}, []string{"--limit-upload 1024", "--limit-download 512"}},
{"only upload", Env{LimitUploadKBps: 256}, []string{"--limit-upload 256"}},
{"zero means omit", Env{LimitUploadKBps: 0, LimitDownloadKBps: 0}, nil},
{"negative means omit", Env{LimitUploadKBps: -1}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
bin := setupScriptBin(t, `echo "$@"`)
env := c.env
env.Bin = bin
lines, h := captureLines()
if err := env.RunUnlock(context.Background(), h); err != nil {
t.Fatalf("RunUnlock: %v", err)
}
joined := strings.Join(*lines, "\n")
for _, want := range c.want {
if !strings.Contains(joined, want) {
t.Fatalf("want %q in argv; got: %s", want, joined)
}
}
if len(c.want) == 0 {
if strings.Contains(joined, "--limit-upload") || strings.Contains(joined, "--limit-download") {
t.Fatalf("expected no limit flags; got: %s", joined)
}
}
})
}
}
func TestRunStatsZeroSnapshots(t *testing.T) {
// Confirms RunStats succeeds and returns a valid *RepoStats when the
// repo has no snapshots (snapshots_count=0). A regression that