diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 8ef3d83..02adfad 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -228,6 +228,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave) r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) + r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit) // Admin credentials form (separate slot for prune-capable user). r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index cd4166d..ad23e03 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -499,6 +499,12 @@ type hostChromeData struct { SourceGroupCount int ScheduleCount int ScheduleVersion int64 // host_schedule_version (latest desired) + + // Auto-init status surfaced from the latest 'init' job. + // InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run). + InitStatus string + InitAt *time.Time // started_at if non-nil else created_at + InitJobID string } // loadHostChrome fetches the per-tab counts that every host-detail tab @@ -520,6 +526,15 @@ func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, cru if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil { d.ScheduleVersion = v } + if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil { + d.InitStatus = j.Status + d.InitJobID = j.ID + t := j.CreatedAt + if j.StartedAt != nil { + t = *j.StartedAt + } + d.InitAt = &t + } return d } diff --git a/internal/server/http/ui_repo_reinit.go b/internal/server/http/ui_repo_reinit.go new file mode 100644 index 0000000..c817df0 --- /dev/null +++ b/internal/server/http/ui_repo_reinit.go @@ -0,0 +1,120 @@ +// ui_repo_reinit.go — danger-zone re-init handler. Dispatches a fresh +// `restic init` job after the operator types the host name to confirm. +// Restic itself refuses to overwrite an existing repo (its init is +// effectively idempotent — see the runner's "config file already +// exists" sniff in restic.RunInit), so this is *not* a destructive +// data wipe; it's a "try again from scratch" affordance for the +// operator. If the rest-server bucket needs clearing the operator +// has to do that out-of-band; the job log will say so. +// +// Audit-logged with action='host.repo_reinit' so the trail records +// who triggered the wipe attempt and when. +package http + +import ( + "context" + "errors" + "log/slog" + stdhttp "net/http" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/api" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func (s *Server) handleUIRepoReinit(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + host, ok := s.loadHostForUI(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + confirm := strings.TrimSpace(r.PostForm.Get("confirm_hostname")) + if confirm != host.Name { + // We don't have a dedicated re-init banner field; surface via + // the existing CredentialsError slot — it sits adjacent to the + // danger zone visually so the operator's eye lands on it. + s.renderRepoPage(w, r, u, host, + "Re-init aborted — typed hostname did not match.", "", "", "") + return + } + if !s.deps.Hub.Connected(host.ID) { + s.renderRepoPage(w, r, u, host, + "Host is offline — bring the agent back up before re-initializing.", + "", "", "") + return + } + // Ensure the host has creds bound; otherwise restic init can't + // connect to the repo. + if _, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo); err != nil { + if errors.Is(err, store.ErrNotFound) { + s.renderRepoPage(w, r, u, host, + "Bind repo credentials before re-initializing.", + "", "", "") + return + } + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + jobID := ulid.Make().String() + now := time.Now().UTC() + if err := s.deps.Store.CreateJob(r.Context(), store.Job{ + ID: jobID, + HostID: host.ID, + Kind: string(api.JobInit), + ActorKind: "user", + ActorID: &u.ID, + CreatedAt: now, + }); err != nil { + slog.Error("repo reinit: persist job", "host_id", host.ID, "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{ + JobID: jobID, + Kind: api.JobInit, + }) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + sendCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := s.deps.Hub.Send(sendCtx, host.ID, env); err != nil { + slog.Warn("repo reinit: ws send failed", "host_id", host.ID, "err", err) + s.renderRepoPage(w, r, u, host, + "Failed to deliver the init job to the agent — try again.", + "", "", "") + return + } + + uid := u.ID + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "host.repo_reinit", + TargetKind: ptr("host"), + TargetID: &host.ID, + TS: now, + }) + + // HTMX redirect → live job log. JSON callers get a 202. + if wantsHTML(r) { + w.Header().Set("HX-Redirect", "/jobs/"+jobID) + w.WriteHeader(stdhttp.StatusNoContent) + return + } + stdhttp.Redirect(w, r, "/jobs/"+jobID, stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_repo_reinit_test.go b/internal/server/http/ui_repo_reinit_test.go new file mode 100644 index 0000000..5b893ec --- /dev/null +++ b/internal/server/http/ui_repo_reinit_test.go @@ -0,0 +1,212 @@ +// ui_repo_reinit_test.go — covers the danger-zone re-init handler: +// hostname-confirm gate + offline guard + missing-creds guard. +package http + +import ( + "context" + stdhttp "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "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/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// rawTestServerWithUI is the rawTestServer twin that also wires the +// UI renderer in, returning the raw httptest server so callers can +// dial /ws/agent. The UI is needed for the repo-reinit handler's +// error re-render path. +func rawTestServerWithUI(t *testing.T) (*Server, *httptest.Server, *store.Store) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + keyPath := filepath.Join(dir, "secret.key") + _ = crypto.GenerateKeyFile(keyPath) + key, _ := crypto.LoadKeyFromFile(keyPath) + aead, _ := crypto.NewAEAD(key) + renderer, err := ui.New() + if err != nil { + t.Fatalf("ui.New: %v", err) + } + deps := Deps{ + Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, + Store: st, + AEAD: aead, + Hub: ws.NewHub(), + UI: renderer, + } + srv := New(deps) + ts := httptest.NewServer(srv.srv.Handler) + t.Cleanup(ts.Close) + return srv, ts, st +} + +// enrolHostForUI is the enrolHostForWS twin for tests that use the +// UI-enabled rawTestServerWithUI. +func enrolHostForUI(t *testing.T, _ *Server, st *store.Store, name string) (hostID, token string) { + t.Helper() + hostID = ulid.Make().String() + token, _ = auth.NewToken() + if err := st.CreateHost(context.Background(), store.Host{ + ID: hostID, Name: name, OS: "linux", Arch: "amd64", + EnrolledAt: time.Now().UTC(), + }, auth.HashToken(token), ""); err != nil { + t.Fatalf("create host: %v", err) + } + return hostID, token +} + +// TestRepoReinitWrongHostnameRejected: typing a different name keeps +// the page on the repo screen with an error banner; no init job is +// dispatched. +func TestRepoReinitWrongHostnameRejected(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, token := enrolHostForUI(t, srv, st, "reinit-host") + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "reinit-host") + _ = drainUntil(t, c, api.MsgScheduleSet) + + cookie := loginAsAdmin(t, st) + + form := url.Values{"confirm_hostname": {"WRONG-NAME"}} + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/repo/reinit", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want 422 (re-rendered page with banner)", res.StatusCode) + } + // No init job should appear in the queue beyond the one auto-init + // pushed on hello (which fires when no init has run yet — let's + // just make sure no new "user" actor init was created). + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'init' AND actor_kind = 'user'`, + hostID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("user-actor init jobs: got %d, want 0 (gate was bypassed)", n) + } +} + +// TestRepoReinitDispatchesOnMatch: typing the right hostname dispatches +// a new init job + audit row. +func TestRepoReinitDispatchesOnMatch(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServerWithUI(t) + hostID, token := enrolHostForUI(t, srv, st, "reinit-ok-host") + // Bind repo creds — re-init guard requires them. + enc, err := srv.encryptRepoCreds(repoCredsBlob{ + RepoURL: "rest:http://r/x", RepoUsername: "u", RepoPassword: "p", + }, []byte("host:"+hostID)) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if err := st.SetHostCredentials(context.Background(), hostID, store.CredKindRepo, enc); err != nil { + t.Fatalf("set creds: %v", err) + } + + // Pre-seed a successful init so auto-init doesn't fire on hello. + preID := ulid.Make().String() + if err := st.CreateJob(context.Background(), store.Job{ + ID: preID, HostID: hostID, Kind: "init", + ActorKind: "system", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed init: %v", err) + } + if err := st.MarkJobFinished(context.Background(), preID, "succeeded", 0, nil, "", time.Now().UTC()); err != nil { + t.Fatalf("mark seed init: %v", err) + } + + c := agentDial(t, srv, ts, hostID, token) + sendHello(t, c, "reinit-ok-host") + _ = drainUntil(t, c, api.MsgScheduleSet) + + cookie := loginAsAdmin(t, st) + + form := url.Values{"confirm_hostname": {"reinit-ok-host"}} + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/hosts/"+hostID+"/repo/reinit", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") // get HX-Redirect path + 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) + } + if res.Header.Get("HX-Redirect") == "" { + t.Fatal("expected HX-Redirect header") + } + + // Read the dispatched command.run; assert it's an init job. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + mt, raw, rerr := c.Read(ctx) + cancel() + if rerr != nil { + break + } + if mt != websocket.MessageText { + continue + } + // Quick parse — we only care about the type. Avoid full + // envelope unmarshal here because the surrounding loop is just + // looking for the command.run we triggered. + if !strings.Contains(string(raw), `"command.run"`) { + continue + } + // Verify a user-actor init job row was created. + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'init' AND actor_kind = 'user'`, + hostID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Fatalf("user-actor init jobs: got %d, want 1", n) + } + // Audit row. + var na int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM audit_log WHERE action = 'host.repo_reinit' AND target_id = ?`, + hostID).Scan(&na); err != nil { + t.Fatalf("audit count: %v", err) + } + if na != 1 { + t.Fatalf("audit rows: got %d, want 1", na) + } + return + } + t.Fatal("timed out waiting for command.run after re-init dispatch") +} diff --git a/web/templates/pages/host_repo.html b/web/templates/pages/host_repo.html index a0caf6e..fda3489 100644 --- a/web/templates/pages/host_repo.html +++ b/web/templates/pages/host_repo.html @@ -238,8 +238,16 @@ secrets.enc is reused.

- +
+ + +
diff --git a/web/templates/partials/host_chrome.html b/web/templates/partials/host_chrome.html index 01606de..9e3f741 100644 --- a/web/templates/partials/host_chrome.html +++ b/web/templates/partials/host_chrome.html @@ -105,6 +105,22 @@ + {{/* ---------- repo init line (P2R-09) ---------- */}} + {{if $page.InitStatus}} +
+ {{if eq $page.InitStatus "succeeded"}} + repo ready · initialised {{relTime $page.InitAt}} + {{else if eq $page.InitStatus "failed"}} + init failed · + job {{$page.InitJobID}} · retry from the Repo tab's danger zone + {{else if eq $page.InitStatus "running"}} + init running… · live log → + {{else if eq $page.InitStatus "queued"}} + init queued · job {{$page.InitJobID}} + {{end}} +
+ {{end}} + {{/* ---------- secondary tabs ---------- */}}
Snapshots {{comma $host.SnapshotCount}}