diff --git a/internal/server/http/fleet_update.go b/internal/server/http/fleet_update.go new file mode 100644 index 0000000..42c67f8 --- /dev/null +++ b/internal/server/http/fleet_update.go @@ -0,0 +1,379 @@ +// fleet_update.go — admin-only fleet rolling-update endpoints + page. +// +// Surface: +// - POST /api/fleet/update → starts a fleet update (JSON) +// - POST /api/fleet-updates/{id}/cancel +// - GET /api/fleet-updates/{id} → JSON parent + per-host array +// - GET /settings/fleet-update → admin UI page +// - GET /settings/fleet-update/partial → htmx polling fragment +// +// All routes are mounted in the admin band (see routes()). +package http + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" + "gitea.dcglab.co.uk/steve/restic-manager/internal/version" +) + +// fleetUpdateStartReq is the JSON body for POST /api/fleet/update. +// Both fields are optional: empty target_version defaults to the +// server's current version, empty host_ids derives the out-of-date +// online subset. +type fleetUpdateStartReq struct { + TargetVersion string `json:"target_version,omitempty"` + HostIDs []string `json:"host_ids,omitempty"` +} + +// fleetUpdateHostView is one row in the JSON response for GET +// /api/fleet-updates/{id}. Hostname is hydrated from the store so +// callers don't need a second round-trip per host. +type fleetUpdateHostView struct { + HostID string `json:"host_id"` + HostName string `json:"host_name,omitempty"` + Position int `json:"position"` + Status string `json:"status"` + JobID string `json:"job_id,omitempty"` + FailedReason string `json:"failed_reason,omitempty"` +} + +// fleetUpdateView is the JSON projection of the parent + children. +type fleetUpdateView struct { + ID string `json:"id"` + StartedAt string `json:"started_at"` + StartedByUserID string `json:"started_by_user_id"` + TargetVersion string `json:"target_version"` + Status string `json:"status"` + CurrentHostID string `json:"current_host_id,omitempty"` + HaltedReason string `json:"halted_reason,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + Hosts []fleetUpdateHostView `json:"hosts"` +} + +// fleetUpdatePage backs both the full /settings/fleet-update page +// and the partial polled fragment. Idle / Active are mutually +// exclusive: if Active is non-nil, render the progress view. +type fleetUpdatePage struct { + // Idle-state fields. + OutOfDateHosts []store.Host // online hosts whose version != target + TargetVersion string + + // Active-state fields. Nil when no fleet update has ever run. + Active *store.FleetUpdate + ActiveRows []fleetUpdateHostView + + // Common. + HostNames map[string]string + // PollURL is the partial endpoint htmx polls every few seconds. + PollURL string +} + +// handleAPIFleetUpdateStart is POST /api/fleet/update. +func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + if s.deps.FleetWorker == nil { + writeJSONError(w, stdhttp.StatusServiceUnavailable, "fleet_worker_unavailable", "") + return + } + var body fleetUpdateStartReq + // Empty body is fine — both fields are optional. + if r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + } + target := body.TargetVersion + if target == "" { + target = version.Version + } + hostIDs := body.HostIDs + if len(hostIDs) == 0 { + derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + hostIDs = derived + } + if len(hostIDs) == 0 { + writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible", + "no online hosts are out of date") + return + } + + fuID, err := s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs) + if err != nil { + if errors.Is(err, store.ErrFleetUpdateRunning) { + writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error()) + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + auditPayload, _ := json.Marshal(map[string]any{ + "fleet_update_id": fuID, + "target_version": target, + "host_count": len(hostIDs), + }) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", + Action: "fleet.update_started", + TargetKind: ptr("fleet_update"), TargetID: &fuID, + TS: time.Now().UTC(), + Payload: auditPayload, + }) + + writeJSON(w, stdhttp.StatusAccepted, map[string]string{"fleet_update_id": fuID}) +} + +// handleAPIFleetUpdateCancel is POST /api/fleet-updates/{id}/cancel. +func (s *Server) handleAPIFleetUpdateCancel(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + if s.deps.FleetWorker == nil { + writeJSONError(w, stdhttp.StatusServiceUnavailable, "fleet_worker_unavailable", "") + return + } + fuID := chi.URLParam(r, "id") + if fuID == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") + return + } + fu, _, err := s.deps.Store.GetFleetUpdate(r.Context(), fuID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "fleet_update_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if fu.Status != "running" { + writeJSONError(w, stdhttp.StatusConflict, "fleet_update_not_running", + "fleet update is not in the running state") + return + } + if err := s.deps.FleetWorker.Cancel(r.Context(), fuID); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", + Action: "fleet.update_cancelled", + TargetKind: ptr("fleet_update"), TargetID: &fuID, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusNoContent) +} + +// handleAPIFleetUpdateGet is GET /api/fleet-updates/{id}. +func (s *Server) handleAPIFleetUpdateGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if _, ok := s.requireUser(r); !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + fuID := chi.URLParam(r, "id") + fu, hosts, err := s.deps.Store.GetFleetUpdate(r.Context(), fuID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "fleet_update_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + names := s.hostNameMap(r) + view := fleetUpdateView{ + ID: fu.ID, + StartedAt: fu.StartedAt.UTC().Format(time.RFC3339Nano), + StartedByUserID: fu.StartedByUserID, + TargetVersion: fu.TargetVersion, + Status: fu.Status, + CurrentHostID: fu.CurrentHostID, + HaltedReason: fu.HaltedReason, + Hosts: make([]fleetUpdateHostView, 0, len(hosts)), + } + if fu.CompletedAt != nil { + s := fu.CompletedAt.UTC().Format(time.RFC3339Nano) + view.CompletedAt = &s + } + for _, h := range hosts { + view.Hosts = append(view.Hosts, fleetUpdateHostView{ + HostID: h.HostID, + HostName: names[h.HostID], + Position: h.Position, + Status: h.Status, + JobID: h.JobID, + FailedReason: h.FailedReason, + }) + } + writeJSON(w, stdhttp.StatusOK, view) +} + +// handleUIFleetUpdate renders /settings/fleet-update. +func (s *Server) handleUIFleetUpdate(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + page, err := s.buildFleetUpdatePage(r) + if err != nil { + slog.Error("ui fleet update: build page", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(r, u) + view.Title = "Fleet update · restic-manager" + view.Active = "settings" + view.Page = page + if err := s.deps.UI.Render(w, "fleet_update", view); err != nil { + slog.Error("ui fleet update: render", "err", err) + } +} + +// handleUIFleetUpdatePartial renders just the inner panel for htmx +// auto-refresh polling — same data, no chrome. +func (s *Server) handleUIFleetUpdatePartial(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + page, err := s.buildFleetUpdatePage(r) + if err != nil { + slog.Error("ui fleet update partial: build page", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(r, u) + view.Page = page + if err := s.deps.UI.RenderPartial(w, "fleet_update_inner", view); err != nil { + slog.Error("ui fleet update partial: render", "err", err) + } +} + +// buildFleetUpdatePage assembles the data both /settings/fleet-update +// and its partial render against. Resolves the most-recent fleet +// update (active OR completed/cancelled/halted) so the page can show +// the last roll's result instead of disappearing into "idle" the +// instant a roll finishes. +func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, error) { + page := fleetUpdatePage{ + TargetVersion: version.Version, + HostNames: map[string]string{}, + PollURL: "/settings/fleet-update/partial", + } + hosts, err := s.deps.Store.ListHosts(r.Context()) + if err != nil { + return page, err + } + for _, h := range hosts { + page.HostNames[h.ID] = h.Name + } + + active, err := s.deps.Store.ActiveFleetUpdate(r.Context()) + if err != nil { + return page, err + } + mostRecent := active + if mostRecent == nil { + // Fall back to the most recent terminal row so the page can + // show "completed" / "halted" / "cancelled" once the worker + // finishes. One small bespoke query — keeps the page from + // flashing back to "idle" the instant a roll wraps up. + var id string + err := s.deps.Store.DB().QueryRowContext(r.Context(), + `SELECT id FROM fleet_updates ORDER BY started_at DESC LIMIT 1`). + Scan(&id) + if err == nil { + fu, _, gerr := s.deps.Store.GetFleetUpdate(r.Context(), id) + if gerr == nil { + mostRecent = fu + } + } + } + + if mostRecent != nil { + _, rows, gerr := s.deps.Store.GetFleetUpdate(r.Context(), mostRecent.ID) + if gerr == nil { + page.Active = mostRecent + page.ActiveRows = make([]fleetUpdateHostView, 0, len(rows)) + for _, hr := range rows { + page.ActiveRows = append(page.ActiveRows, fleetUpdateHostView{ + HostID: hr.HostID, + HostName: page.HostNames[hr.HostID], + Position: hr.Position, + Status: hr.Status, + JobID: hr.JobID, + FailedReason: hr.FailedReason, + }) + } + } + } + + // Idle list (or "still out of date" reference even when an active + // roll is running — cheap to compute, harmless to attach). + for _, h := range hosts { + if h.Status != "online" { + continue + } + if h.AgentVersion == "" || h.AgentVersion == page.TargetVersion { + continue + } + page.OutOfDateHosts = append(page.OutOfDateHosts, h) + } + return page, nil +} + +// deriveOutOfDateOnlineHostIDs returns the list of host IDs that +// (a) are online (Hub.Connected) and (b) have an agent_version that's +// non-empty AND != target. Used by the start endpoint when the caller +// omits host_ids. +func (s *Server) deriveOutOfDateOnlineHostIDs(ctx context.Context, target string) ([]string, error) { + hosts, err := s.deps.Store.ListHosts(ctx) + if err != nil { + return nil, err + } + out := []string{} + for _, h := range hosts { + if h.AgentVersion == "" || h.AgentVersion == target { + continue + } + if !s.deps.Hub.Connected(h.ID) { + continue + } + out = append(out, h.ID) + } + return out, nil +} + +// hostNameMap returns hostID → name; used to hydrate fleet-update +// JSON responses. +func (s *Server) hostNameMap(r *stdhttp.Request) map[string]string { + out := map[string]string{} + hosts, err := s.deps.Store.ListHosts(r.Context()) + if err != nil { + return out + } + for _, h := range hosts { + out[h.ID] = h.Name + } + return out +} diff --git a/internal/server/http/fleet_update_test.go b/internal/server/http/fleet_update_test.go new file mode 100644 index 0000000..ca82561 --- /dev/null +++ b/internal/server/http/fleet_update_test.go @@ -0,0 +1,334 @@ +// fleet_update_test.go — coverage for the P6-15 fleet-update HTTP +// surface: start/cancel/get JSON endpoints + RBAC. +package http + +import ( + "bytes" + "context" + "encoding/json" + stdhttp "net/http" + "sync" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" + "gitea.dcglab.co.uk/steve/restic-manager/internal/version" +) + +// fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests. +// It records what was passed to Start/Cancel and lets tests inject +// canned errors. Satisfies the FleetWorker interface in +// host_update.go. +type fakeFleetWorker struct { + mu sync.Mutex + + startCalls []fakeStartCall + startID string + startErr error + + cancelCalls []string + cancelErr error +} + +type fakeStartCall struct { + UserID string + Target string + HostIDs []string +} + +func (f *fakeFleetWorker) Start(_ context.Context, userID, target string, hostIDs []string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.startCalls = append(f.startCalls, fakeStartCall{userID, target, append([]string(nil), hostIDs...)}) + if f.startErr != nil { + return "", f.startErr + } + return f.startID, nil +} + +func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.cancelCalls = append(f.cancelCalls, id) + return f.cancelErr +} + +// helloOnlineHost is the smallest setup that lets the dispatch / +// derivation logic see a host as "online + version mismatch". +// Returns the host id. +func helloOnlineHost(t *testing.T, srv *Server, st *store.Store, name, agentVer string) string { + t.Helper() + id := makeHost(t, st, name) + if err := st.MarkHostHello(context.Background(), id, agentVer, "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil { + t.Fatalf("mark hello: %v", err) + } + // Mark connected on the hub so deriveOutOfDateOnlineHostIDs + // considers it online without needing a real WS handshake. The + // Conn has a nil websocket pointer — tests never call Send on it. + srv.deps.Hub.Register(id, ws.NewConn(id, nil)) + return id +} + +func TestFleetUpdateStartHappyPath(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + worker := &fakeFleetWorker{startID: ulid.Make().String()} + srv.deps.FleetWorker = worker + + cookie, uid := loginAsAdminWithID(t, st) + hostID := helloOnlineHost(t, srv, st, "fu-host", "v0") + + body := map[string]any{"host_ids": []string{hostID}} + raw, _ := json.Marshal(body) + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader(raw)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusAccepted { + t.Fatalf("status: got %d, want 202", res.StatusCode) + } + var out struct { + FleetUpdateID string `json:"fleet_update_id"` + } + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.FleetUpdateID != worker.startID { + t.Fatalf("fleet_update_id: got %q, want %q", out.FleetUpdateID, worker.startID) + } + worker.mu.Lock() + if len(worker.startCalls) != 1 || worker.startCalls[0].UserID != uid { + t.Fatalf("start calls: %+v", worker.startCalls) + } + if got := worker.startCalls[0].HostIDs; len(got) != 1 || got[0] != hostID { + t.Fatalf("host_ids: %v", got) + } + worker.mu.Unlock() + + // Audit row. + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'fleet.update_started' AND target_id = ?`, + out.FleetUpdateID).Scan(&n); err != nil { + t.Fatalf("audit count: %v", err) + } + if n != 1 { + t.Fatalf("audit rows: got %d, want 1", n) + } +} + +func TestFleetUpdateStartConflictWhenAlreadyRunning(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + worker := &fakeFleetWorker{startErr: store.ErrFleetUpdateRunning} + srv.deps.FleetWorker = worker + cookie := loginAsAdmin(t, st) + _ = helloOnlineHost(t, srv, st, "fu-host", "v0") + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`))) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Fatalf("status: got %d, want 409", res.StatusCode) + } + body := readJSONError(t, res.Body) + if body.Code != "fleet_update_in_progress" { + t.Fatalf("code: %q", body.Code) + } +} + +func TestFleetUpdateStartDerivesHostIDsWhenEmpty(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + worker := &fakeFleetWorker{startID: ulid.Make().String()} + srv.deps.FleetWorker = worker + cookie := loginAsAdmin(t, st) + + // Two online + out-of-date, one online + at-target, one offline. + a := helloOnlineHost(t, srv, st, "behind-a", "v0") + b := helloOnlineHost(t, srv, st, "behind-b", "v0") + _ = helloOnlineHost(t, srv, st, "uptodate", version.Version) + offlineID := makeHost(t, st, "offline-host") + if err := st.MarkHostHello(context.Background(), offlineID, "v0", "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil { + t.Fatalf("mark hello: %v", err) + } + // Don't MarkOnline → derivation should skip. + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`))) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusAccepted { + t.Fatalf("status: got %d, want 202", res.StatusCode) + } + worker.mu.Lock() + defer worker.mu.Unlock() + if len(worker.startCalls) != 1 { + t.Fatalf("start calls: %d", len(worker.startCalls)) + } + got := worker.startCalls[0].HostIDs + want := map[string]bool{a: true, b: true} + if len(got) != 2 || !want[got[0]] || !want[got[1]] { + t.Fatalf("derived host_ids: got %v, want both of %v", got, []string{a, b}) + } +} + +func TestFleetUpdateCancelHappyPath(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + worker := &fakeFleetWorker{} + srv.deps.FleetWorker = worker + cookie := loginAsAdmin(t, st) + + // Seed a running fleet update directly. + fuID := ulid.Make().String() + uid := ulid.Make().String() + if err := st.CreateUser(context.Background(), store.User{ + ID: uid, Username: "starter", PasswordHash: "x", + Role: store.RoleAdmin, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed user: %v", err) + } + hostID := makeHost(t, st, "fu-cancel-host") + if err := st.CreateFleetUpdate(context.Background(), + store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1"}, + []string{hostID}); err != nil { + t.Fatalf("seed fleet update: %v", err) + } + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet-updates/"+fuID+"/cancel", nil) + 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.StatusNoContent { + t.Fatalf("status: got %d, want 204", res.StatusCode) + } + worker.mu.Lock() + if len(worker.cancelCalls) != 1 || worker.cancelCalls[0] != fuID { + t.Fatalf("cancel calls: %v", worker.cancelCalls) + } + worker.mu.Unlock() +} + +func TestFleetUpdateCancelNotRunning(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + srv.deps.FleetWorker = &fakeFleetWorker{} + cookie := loginAsAdmin(t, st) + + // Seed + complete one so it's no longer running. + fuID := ulid.Make().String() + uid := ulid.Make().String() + _ = st.CreateUser(context.Background(), store.User{ + ID: uid, Username: "starter2", PasswordHash: "x", + Role: store.RoleAdmin, CreatedAt: time.Now().UTC(), + }) + hostID := makeHost(t, st, "fu-done-host") + _ = st.CreateFleetUpdate(context.Background(), + store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1"}, + []string{hostID}) + if err := st.CompleteFleetUpdate(context.Background(), fuID, time.Now().UTC()); err != nil { + t.Fatalf("complete: %v", err) + } + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet-updates/"+fuID+"/cancel", nil) + 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.StatusConflict { + t.Fatalf("status: got %d, want 409", res.StatusCode) + } + body := readJSONError(t, res.Body) + if body.Code != "fleet_update_not_running" { + t.Fatalf("code: %q", body.Code) + } +} + +func TestFleetUpdateGetHydrates(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServer(t) + cookie := loginAsAdmin(t, st) + + uid := ulid.Make().String() + _ = st.CreateUser(context.Background(), store.User{ + ID: uid, Username: "starter3", PasswordHash: "x", + Role: store.RoleAdmin, CreatedAt: time.Now().UTC(), + }) + hostID := makeHost(t, st, "fu-get-host") + fuID := ulid.Make().String() + if err := st.CreateFleetUpdate(context.Background(), + store.FleetUpdate{ID: fuID, StartedByUserID: uid, TargetVersion: "v1.2.3"}, + []string{hostID}); err != nil { + t.Fatalf("seed: %v", err) + } + + req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/fleet-updates/"+fuID, nil) + 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.StatusOK { + t.Fatalf("status: got %d, want 200", res.StatusCode) + } + var got fleetUpdateView + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.ID != fuID || got.TargetVersion != "v1.2.3" || got.Status != "running" { + t.Fatalf("parent: %+v", got) + } + if len(got.Hosts) != 1 || got.Hosts[0].HostID != hostID || got.Hosts[0].HostName != "fu-get-host" { + t.Fatalf("hosts: %+v", got.Hosts) + } +} + +func TestFleetUpdateRBAC(t *testing.T) { + t.Parallel() + _, ts, st := rawTestServer(t) + + for _, role := range []store.Role{store.RoleViewer, store.RoleOperator} { + role := role + t.Run(string(role), func(t *testing.T) { + cookie := loginAsRole(t, st, role) + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader([]byte(`{}`))) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusForbidden { + t.Fatalf("status: got %d, want 403", res.StatusCode) + } + }) + } +} + +// Sanity check that fakeFleetWorker satisfies the FleetWorker iface. +var _ FleetWorker = (*fakeFleetWorker)(nil) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 67aeaf4..17ecc7a 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -285,6 +285,11 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/hosts/{id}/update", s.handleHostUpdate) r.Post("/hosts/{id}/update", s.handleHostUpdateForm) + // Fleet update (P6-15): rolling update across many hosts. + r.Post("/api/fleet/update", s.handleAPIFleetUpdateStart) + r.Post("/api/fleet-updates/{id}/cancel", s.handleAPIFleetUpdateCancel) + r.Get("/api/fleet-updates/{id}", s.handleAPIFleetUpdateGet) + r.Get("/api/users", s.handleAPIUsersList) r.Post("/api/users", s.handleAPIUserCreate) r.Get("/api/users/{id}", s.handleAPIUserGet) @@ -298,6 +303,8 @@ func (s *Server) routes(r chi.Router) { if s.deps.UI != nil { r.Post("/hosts/{id}/delete", s.handleUIHostDelete) r.Get("/settings", s.handleUISettings) + r.Get("/settings/fleet-update", s.handleUIFleetUpdate) + r.Get("/settings/fleet-update/partial", s.handleUIFleetUpdatePartial) r.Get("/settings/users", s.handleUIUsersList) r.Get("/settings/users/new", s.handleUIUserNewGet) r.Post("/settings/users/new", s.handleUIUserNewPost) diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 3b3c446..45e5af7 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -108,6 +108,8 @@ func New() (*Renderer, error) { "templates/partials/tree_node.html", "templates/partials/alert_row.html", "templates/partials/crit_banner.html", + "templates/partials/fleet_update_inner.html", + "templates/partials/host_update_chip.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/web/templates/pages/fleet_update.html b/web/templates/pages/fleet_update.html new file mode 100644 index 0000000..3373e0d --- /dev/null +++ b/web/templates/pages/fleet_update.html @@ -0,0 +1,32 @@ +{{define "title"}}Fleet update · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+ Rolling, sequential agent self-update. One host at a time, halts on first failure, + cancellable mid-roll. Only online hosts whose agent_version + differs from the server are eligible. +
+