Make snapshot projections self-diagnosing
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m32s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m32s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
This commit is contained in:
@@ -262,6 +262,7 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
|
||||
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
|
||||
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
||||
r.Post("/api/hosts/{id}/snapshots/refresh", s.handleRefreshHostSnapshots)
|
||||
|
||||
// HTMX form variants outside /api.
|
||||
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// snapshotView is the public JSON shape for a snapshot. Matches the
|
||||
@@ -26,6 +30,7 @@ type listSnapshotsResponse struct {
|
||||
HostID string `json:"host_id"`
|
||||
Count int `json:"count"`
|
||||
RefreshedAt *time.Time `json:"refreshed_at,omitempty"`
|
||||
Stale bool `json:"stale"`
|
||||
Snapshots []snapshotView `json:"snapshots"`
|
||||
}
|
||||
|
||||
@@ -45,7 +50,8 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
@@ -61,10 +67,13 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
Count: len(snaps),
|
||||
Snapshots: make([]snapshotView, len(snaps)),
|
||||
}
|
||||
if len(snaps) > 0 {
|
||||
t := snaps[0].RefreshedAt
|
||||
out.RefreshedAt = &t
|
||||
out.RefreshedAt = host.SnapshotRefreshedAt
|
||||
mutationAt, err := s.deps.Store.LatestSuccessfulRepoMutation(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
out.Stale = mutationAt != nil && (out.RefreshedAt == nil || out.RefreshedAt.Before(*mutationAt))
|
||||
for i, sn := range snaps {
|
||||
out.Snapshots[i] = snapshotView{
|
||||
ID: sn.ID,
|
||||
@@ -80,3 +89,31 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
|
||||
writeJSON(w, stdhttp.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleRefreshHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
user, ok := s.requireUser(r)
|
||||
if !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "host_offline", "agent is not currently connected")
|
||||
return
|
||||
}
|
||||
env, _ := api.Marshal(api.MsgSnapshotsRefresh, ulid.Make().String(), nil)
|
||||
if err := s.deps.Hub.Send(r.Context(), hostID, env); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "host_offline", err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
|
||||
Action: "host.snapshots_refresh", TargetKind: ptr("host"), TargetID: &hostID, TS: now,
|
||||
})
|
||||
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "refresh_requested"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stdhttp "net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
func TestSnapshotsFreshnessAndExplicitRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServerWithUI(t)
|
||||
hostID, token := enrolHostForUI(t, srv, st, "snapshot-refresh-host")
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "snapshot-refresh-host")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
cookie := loginAsAdmin(t, st)
|
||||
|
||||
mutationAt := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: "mutation-job", HostID: hostID, Kind: "forget", ActorKind: "user", CreatedAt: mutationAt.Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create mutation: %v", err)
|
||||
}
|
||||
if err := st.MarkJobFinished(context.Background(), "mutation-job", "succeeded", 0, nil, "", mutationAt); err != nil {
|
||||
t.Fatalf("finish mutation: %v", err)
|
||||
}
|
||||
|
||||
get := func() listSnapshotsResponse {
|
||||
req, _ := stdhttp.NewRequest(stdhttp.MethodGet, ts.URL+"/api/hosts/"+hostID+"/snapshots", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get snapshots: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var body listSnapshotsResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode snapshots: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
if body := get(); !body.Stale || body.RefreshedAt != nil {
|
||||
t.Fatalf("unrefreshed projection should be stale: %+v", body)
|
||||
}
|
||||
|
||||
refreshedAt := mutationAt.Add(time.Second)
|
||||
if err := st.ReplaceHostSnapshots(context.Background(), hostID, nil, refreshedAt); err != nil {
|
||||
t.Fatalf("replace empty: %v", err)
|
||||
}
|
||||
if body := get(); body.Stale || body.RefreshedAt == nil || !body.RefreshedAt.Equal(refreshedAt) {
|
||||
t.Fatalf("fresh empty projection reported incorrectly: %+v", body)
|
||||
}
|
||||
|
||||
req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/snapshots/refresh", nil)
|
||||
req.AddCookie(cookie)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request refresh: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusAccepted {
|
||||
t.Fatalf("refresh status = %d, want 202", res.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, raw, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read refresh command: %v", err)
|
||||
}
|
||||
var env api.Envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if env.Type != api.MsgSnapshotsRefresh {
|
||||
t.Fatalf("message type = %q, want %q", env.Type, api.MsgSnapshotsRefresh)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user