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:
+38
-23
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user