ui: P2R-09 auto-init UX — init line in chrome + danger-zone re-init
Latest 'init' job status surfaced under the host-detail vitals strip
(succeeded/failed/running/queued, with link to the live job log on
non-success). New POST /hosts/{id}/repo/reinit handler dispatches a
fresh init job after the operator types the host name to confirm;
audit row records 'host.repo_reinit'.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user