From e6fc9e99634f15fcb4eb8855d7ab6146939c15e2 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 10:41:13 +0100 Subject: [PATCH] ui+server: per-job bandwidth override on Run-now P2R-13b. POST /hosts/{id}/source-groups/{gid}/run accepts optional bandwidth_up_kbps / bandwidth_down_kbps form fields, plumbs them onto CommandRunPayload. Agent dispatcher already prefers per-job override over host-wide caps (T1). UI wraps the Run-now button in a form with a
'Limit bandwidth for this run' disclosure containing two KB/s inputs. --- internal/server/http/run_group.go | 47 ++++++- .../server/http/run_group_bandwidth_test.go | 133 ++++++++++++++++++ web/templates/pages/host_sources.html | 25 +++- 3 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 internal/server/http/run_group_bandwidth_test.go diff --git a/internal/server/http/run_group.go b/internal/server/http/run_group.go index 1a0f35c..2b8d952 100644 --- a/internal/server/http/run_group.go +++ b/internal/server/http/run_group.go @@ -9,6 +9,7 @@ package http import ( "errors" stdhttp "net/http" + "strconv" "github.com/go-chi/chi/v5" @@ -16,6 +17,34 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) +// parseBandwidthOverride pulls optional bandwidth_up_kbps / +// bandwidth_down_kbps from the request (form or query). Returns nil +// for any field absent or empty; an explicit "0" produces a non-nil +// pointer to 0 — i.e., "no cap for this run, even if the host has +// one set." Non-integers / negatives are rejected with an error. +func parseBandwidthOverride(r *stdhttp.Request) (up *int, down *int, err error) { + parse := func(name string) (*int, error) { + v := r.FormValue(name) + if v == "" { + return nil, nil + } + n, perr := strconv.Atoi(v) + if perr != nil { + return nil, errors.New(name + " must be an integer") + } + if n < 0 { + return nil, errors.New(name + " must be >= 0") + } + return &n, nil + } + up, err = parse("bandwidth_up_kbps") + if err != nil { + return nil, nil, err + } + down, err = parse("bandwidth_down_kbps") + return up, down, err +} + func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { @@ -40,13 +69,25 @@ func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Reque return } + // Optional per-run bandwidth override. Disclosed in the UI under a + //
"Limit bandwidth for this run" affordance; absent on + // the wire (and from JSON callers that don't supply it) means + // "fall back to the host's standing caps." + upOverride, downOverride, perr := parseBandwidthOverride(r) + if perr != nil { + s.runGroupError(w, r, stdhttp.StatusBadRequest, "invalid_value", perr.Error()) + return + } + // Backup invocations don't consume RetentionPolicy — that lives on // forget. Sending the resolved set here would just be dead weight. res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup, api.CommandRunPayload{ - Includes: g.Includes, - Excludes: g.Excludes, - Tag: g.Name, + Includes: g.Includes, + Excludes: g.Excludes, + Tag: g.Name, + BandwidthUpKBps: upOverride, + BandwidthDownKBps: downOverride, }) if code != "" { s.runGroupError(w, r, status, code, msg) diff --git a/internal/server/http/run_group_bandwidth_test.go b/internal/server/http/run_group_bandwidth_test.go new file mode 100644 index 0000000..36c01a3 --- /dev/null +++ b/internal/server/http/run_group_bandwidth_test.go @@ -0,0 +1,133 @@ +// run_group_bandwidth_test.go — covers the per-job bandwidth override +// that operators can set via the Run-now form's "Limit bandwidth for +// this run" disclosure (P2R-13b). +package http + +import ( + "context" + "encoding/json" + stdhttp "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// TestRunSourceGroupBandwidthOverride: connect a fake agent, POST the +// per-group Run-now endpoint with bandwidth_up_kbps=512, assert the +// dispatched command.run carries it. +func TestRunSourceGroupBandwidthOverride(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, token := enrolHostForWS(t, srv, st, "bw-host") + + // Pre-seed an init job so auto-init doesn't fire on hello and + // pollute our envelope sequence. + if err := st.CreateJob(context.Background(), store.Job{ + ID: ulid.Make().String(), HostID: hostID, Kind: "init", + ActorKind: "system", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed init: %v", err) + } + + gid := ulid.Make().String() + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "etc", Includes: []string{"/etc"}, + }); err != nil { + t.Fatalf("group: %v", err) + } + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "bw-host") + // Drain on-hello burst before issuing the run-now. + _ = drainUntil(t, c, api.MsgScheduleSet) + + cookie := loginAsAdmin(t, st) + form := url.Values{ + "bandwidth_up_kbps": {"512"}, + "bandwidth_down_kbps": {"256"}, + } + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/source-groups/"+gid+"/run", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + res.Body.Close() + if res.StatusCode != stdhttp.StatusAccepted { + t.Fatalf("status: got %d, want 202", res.StatusCode) + } + + // Read the dispatched command.run; assert overrides are present. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + mt, raw, rerr := c.Read(ctx) + cancel() + if rerr != nil { + break + } + if mt != websocket.MessageText { + continue + } + var env api.Envelope + _ = json.Unmarshal(raw, &env) + if env.Type != api.MsgCommandRun { + continue + } + var p api.CommandRunPayload + if err := env.UnmarshalPayload(&p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.Kind != api.JobBackup { + continue + } + if p.BandwidthUpKBps == nil || *p.BandwidthUpKBps != 512 { + t.Fatalf("BandwidthUpKBps: got %v, want 512", p.BandwidthUpKBps) + } + if p.BandwidthDownKBps == nil || *p.BandwidthDownKBps != 256 { + t.Fatalf("BandwidthDownKBps: got %v, want 256", p.BandwidthDownKBps) + } + return + } + t.Fatal("timed out waiting for command.run with bandwidth override") +} + +// TestRunSourceGroupBandwidthRejectsNegative: invalid value → 400. +func TestRunSourceGroupBandwidthRejectsNegative(t *testing.T) { + t.Parallel() + _, url2, st := newTestServerWithHub(t) + cookie := loginAsAdmin(t, st) + hostID := makeHost(t, st, "bw-rej-host") + gid := ulid.Make().String() + if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{ + ID: gid, HostID: hostID, Name: "etc", Includes: []string{"/etc"}, + }); err != nil { + t.Fatalf("group: %v", err) + } + form := url.Values{"bandwidth_up_kbps": {"-1"}} + req, _ := stdhttp.NewRequest("POST", + url2+"/hosts/"+hostID+"/source-groups/"+gid+"/run", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusBadRequest { + t.Fatalf("status: got %d, want 400", res.StatusCode) + } +} diff --git a/web/templates/pages/host_sources.html b/web/templates/pages/host_sources.html index 36a8077..d0d1087 100644 --- a/web/templates/pages/host_sources.html +++ b/web/templates/pages/host_sources.html @@ -53,12 +53,27 @@ {{if gt $row.SnapshotCount 0}} · {{$row.SnapshotCount}} snapshot{{if ne $row.SnapshotCount 1}}s{{end}}{{end}} -
+
{{if and (gt (len $g.Includes) 0) (eq $host.Status "online")}} - +
+ +
+ Limit bandwidth for this run +
+ + + + + KB/s +
+
+
{{else}}