d000fe7ec1
Schedules CRUD now takes {cron, enabled, source_group_ids[]} with cron
parsed via robfig/cron/v3 and group membership scoped to the host.
New source-groups CRUD lives at /api/hosts/{id}/source-groups; delete
refuses with 409 if any schedule still references the group, returning
the schedule list so the UI can prompt 'remove from these schedules
first.' Repo-maintenance GET/PUT manages forget/prune/check cadences
on host_repo_maintenance — no version bump, the server-side ticker
(P2R-06) drives execution.
Per-source-group Run-now (POST /hosts/{id}/source-groups/{gid}/run)
resolves the group's includes/excludes/retention/tag and dispatches a
backup command.run with the new structured CommandRunPayload fields
(Includes/Excludes/Tag). Old per-host /hosts/{id}/run-backup and
/hosts/{id}/init-repo return 410 Gone with a redirect message.
schedule_push.go is rebuilt: buildScheduleSetPayload assembles the
slim wire shape, pushScheduleSetOnConn ships it during the on-hello
window, pushScheduleSetAsync fires after every CRUD mutation, and
dispatchScheduledJob handles agent schedule.fire by iterating the
schedule's source groups and dispatching one backup per group with
actor_kind=schedule and scheduled_id pointing at the schedule.
Auto-init at first WS connect: when the host has repo creds bound and
no init job in its history, server dispatches restic init. Restic's
'config file already exists' soft-success means re-runs against an
existing repo no-op; we don't auto-retry on failure (operator triggers
re-init manually via the danger zone in P2R-09).
api.Schedule drops Kind/Paths/Excludes/Tags/RetentionPolicy/Manual etc.
in favour of {id, cron, enabled, source_groups: [...]}. The agent
scheduler stops checking sch.Manual; cmd/agent's backup dispatch reads
Includes/Excludes/Tag instead of Args.
Tests cover the new HTTP surface end-to-end: source-groups CRUD with
in-use refusal, schedule validation (bad cron / missing groups /
foreign group), repo-maintenance auto-seed and validation, the 410
route, and buildScheduleSetPayload's wire-shape correctness. Full
suite passes; smoke env exercises auto-init dispatch on hello,
async push after schedule create, and per-source-group Run-now
landing the right paths/excludes/tag at the agent.
483 lines
14 KiB
Go
483 lines
14 KiB
Go
// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface
|
|
// landed in P2R-01: schedules, source-groups, repo-maintenance, the
|
|
// per-source-group Run-now endpoint, schedule_push reconciliation,
|
|
// and auto-init at hello.
|
|
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/store"
|
|
)
|
|
|
|
// loginAsAdmin creates an admin user + a session in the store and
|
|
// returns a cookie ready to attach to outgoing requests.
|
|
func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
uid := ulid.Make().String()
|
|
hash, _ := auth.HashPassword("very-long-test-password")
|
|
if err := st.CreateUser(ctx, store.User{
|
|
ID: uid, Username: "tester-" + uid[:6],
|
|
PasswordHash: hash, Role: store.RoleAdmin,
|
|
CreatedAt: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
tok, _ := auth.NewToken()
|
|
if err := st.CreateSession(ctx, store.Session{
|
|
UserID: uid,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().Add(time.Hour).UTC(),
|
|
}, auth.HashToken(tok)); err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}
|
|
}
|
|
|
|
// makeHost inserts a minimal Host row directly via the store. Used by
|
|
// HTTP-level tests that don't want to go through the full enrollment
|
|
// path. Returns the host id.
|
|
func makeHost(t *testing.T, st *store.Store, name string) string {
|
|
t.Helper()
|
|
id := ulid.Make().String()
|
|
if err := st.CreateHost(context.Background(), store.Host{
|
|
ID: id, Name: name, OS: "linux", Arch: "amd64",
|
|
ProtocolVersion: api.CurrentProtocolVersion,
|
|
EnrolledAt: time.Now().UTC(),
|
|
}, "tokhash-"+id, ""); err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// doJSON issues a JSON request with the given method and body, returns
|
|
// status + decoded JSON map (nil on empty body).
|
|
func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
raw, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(raw)
|
|
}
|
|
req, err := stdhttp.NewRequest(method, baseURL+path, rdr)
|
|
if err != nil {
|
|
t.Fatalf("new req: %v", err)
|
|
}
|
|
if rdr != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
res, err := stdhttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
raw, _ := io.ReadAll(res.Body)
|
|
if len(raw) == 0 {
|
|
return res.StatusCode, nil
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
// Non-JSON (HTMX action paths return plain text on error).
|
|
return res.StatusCode, map[string]any{"raw": string(raw)}
|
|
}
|
|
return res.StatusCode, out
|
|
}
|
|
|
|
// ----- source-groups ------------------------------------------------
|
|
|
|
func TestSourceGroupsCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "sg-host")
|
|
|
|
// Empty list at start.
|
|
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("list status: %d", status)
|
|
}
|
|
if got := body["source_groups"].([]any); len(got) != 0 {
|
|
t.Fatalf("expected empty list, got %d", len(got))
|
|
}
|
|
|
|
// Create.
|
|
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
|
map[string]any{
|
|
"name": "etc",
|
|
"includes": []string{"/etc"},
|
|
"excludes": []string{"/etc/shadow"},
|
|
"retention_policy": map[string]int{
|
|
"keep_daily": 7,
|
|
},
|
|
"retry_max": 3,
|
|
"retry_backoff_seconds": 60,
|
|
}, cookie)
|
|
if status != 201 {
|
|
t.Fatalf("create status: %d, body: %+v", status, body)
|
|
}
|
|
gid, _ := body["id"].(string)
|
|
if gid == "" {
|
|
t.Fatalf("create: no id returned: %+v", body)
|
|
}
|
|
|
|
// Duplicate name → 409.
|
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
|
map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie)
|
|
if status != 409 {
|
|
t.Errorf("duplicate name: want 409, got %d", status)
|
|
}
|
|
|
|
// Update — rename + add another include.
|
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
|
|
map[string]any{
|
|
"name": "system",
|
|
"includes": []string{"/etc", "/var/log"},
|
|
"retention_policy": map[string]int{
|
|
"keep_daily": 14,
|
|
"keep_weekly": 4,
|
|
},
|
|
}, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("update status: %d, body: %+v", status, body)
|
|
}
|
|
if got := body["name"]; got != "system" {
|
|
t.Errorf("rename: got %v want system", got)
|
|
}
|
|
|
|
// Delete.
|
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
|
if status != 204 {
|
|
t.Errorf("delete status: %d", status)
|
|
}
|
|
|
|
// Already gone.
|
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
|
if status != 404 {
|
|
t.Errorf("delete-after-delete: want 404, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "sg-inuse-host")
|
|
|
|
// Create a group via the store directly.
|
|
gid := ulid.Make().String()
|
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"},
|
|
}); err != nil {
|
|
t.Fatalf("create group: %v", err)
|
|
}
|
|
// Attach a schedule.
|
|
sid := ulid.Make().String()
|
|
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
|
ID: sid, HostID: hostID,
|
|
CronExpr: "0 3 * * *", Enabled: true,
|
|
SourceGroupIDs: []string{gid},
|
|
}); err != nil {
|
|
t.Fatalf("create schedule: %v", err)
|
|
}
|
|
|
|
status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
|
if status != 409 {
|
|
t.Fatalf("want 409, got %d body=%+v", status, body)
|
|
}
|
|
if body["code"] != "group_in_use" {
|
|
t.Errorf("wrong code: %+v", body)
|
|
}
|
|
}
|
|
|
|
// ----- schedules ----------------------------------------------------
|
|
|
|
func TestSchedulesCRUDValidation(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "sched-host")
|
|
|
|
// Bad cron → 400.
|
|
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
|
map[string]any{"cron": "not-a-cron", "enabled": true,
|
|
"source_group_ids": []string{"x"}}, cookie)
|
|
if status != 400 {
|
|
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
|
|
}
|
|
|
|
// Missing groups → 400.
|
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
|
"source_group_ids": []string{}}, cookie)
|
|
if status != 400 {
|
|
t.Errorf("missing groups: want 400, got %d", status)
|
|
}
|
|
|
|
// Group not on host → 400.
|
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
|
"source_group_ids": []string{"non-existent"}}, cookie)
|
|
if status != 400 {
|
|
t.Errorf("bogus group: want 400, got %d", status)
|
|
}
|
|
|
|
// Create a real group.
|
|
gid := ulid.Make().String()
|
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
|
|
}); err != nil {
|
|
t.Fatalf("group: %v", err)
|
|
}
|
|
|
|
// Happy create.
|
|
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
|
"source_group_ids": []string{gid}}, cookie)
|
|
if status != 201 {
|
|
t.Fatalf("create: %d body=%+v", status, body)
|
|
}
|
|
sid, _ := body["id"].(string)
|
|
if sid == "" {
|
|
t.Fatalf("no id: %+v", body)
|
|
}
|
|
|
|
// List.
|
|
status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("list: %d", status)
|
|
}
|
|
rows, _ := body["schedules"].([]any)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected 1 schedule, got %d", len(rows))
|
|
}
|
|
|
|
// Update — change cron, keep group.
|
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
|
|
map[string]any{"cron": "@hourly", "enabled": false,
|
|
"source_group_ids": []string{gid}}, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("update: %d body=%+v", status, body)
|
|
}
|
|
if body["cron"] != "@hourly" || body["enabled"] != false {
|
|
t.Errorf("update fields: %+v", body)
|
|
}
|
|
|
|
// Delete.
|
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie)
|
|
if status != 204 {
|
|
t.Errorf("delete: %d", status)
|
|
}
|
|
}
|
|
|
|
// ----- repo-maintenance --------------------------------------------
|
|
|
|
func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "maint-host")
|
|
|
|
// GET on a host that hasn't had the row seeded auto-creates one.
|
|
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("get: %d body=%+v", status, body)
|
|
}
|
|
if body["host_id"] != hostID {
|
|
t.Errorf("host_id mismatch: %+v", body)
|
|
}
|
|
|
|
// PUT with bad cron.
|
|
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
|
map[string]any{
|
|
"forget_cron": "junk", "prune_cron": "@weekly",
|
|
"check_cron": "@monthly", "check_subset_pct": 10,
|
|
}, cookie)
|
|
if status != 400 {
|
|
t.Errorf("bad cron: want 400, got %d", status)
|
|
}
|
|
|
|
// PUT with subset out of range.
|
|
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
|
map[string]any{
|
|
"forget_cron": "@daily", "prune_cron": "@weekly",
|
|
"check_cron": "@monthly", "check_subset_pct": 200,
|
|
}, cookie)
|
|
if status != 400 {
|
|
t.Errorf("bad subset: want 400, got %d", status)
|
|
}
|
|
|
|
// Happy PUT.
|
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
|
map[string]any{
|
|
"forget_cron": "@daily",
|
|
"forget_enabled": true,
|
|
"prune_cron": "@weekly",
|
|
"prune_enabled": true,
|
|
"check_cron": "@monthly",
|
|
"check_enabled": false,
|
|
"check_subset_pct": 25,
|
|
}, cookie)
|
|
if status != 200 {
|
|
t.Fatalf("happy put: %d body=%+v", status, body)
|
|
}
|
|
if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) {
|
|
t.Errorf("fields: %+v", body)
|
|
}
|
|
}
|
|
|
|
// ----- 410 Gone on retired routes ----------------------------------
|
|
|
|
func TestPerHostRunBackupReturns410(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "gone-host")
|
|
|
|
req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil)
|
|
req.AddCookie(cookie)
|
|
res, err := stdhttp.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusGone {
|
|
t.Errorf("want 410, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ----- schedule_push payload ---------------------------------------
|
|
|
|
func TestBuildScheduleSetPayload(t *testing.T) {
|
|
t.Parallel()
|
|
srv, _, st := newTestServerWithHub(t)
|
|
hostID := makeHost(t, st, "push-host")
|
|
|
|
gid := ulid.Make().String()
|
|
keepDaily := 7
|
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
|
ID: gid, HostID: hostID, Name: "default",
|
|
Includes: []string{"/etc", "/home"},
|
|
Excludes: []string{"/etc/shadow"},
|
|
RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
|
|
RetryMax: 2, RetryBackoffSeconds: 30,
|
|
}); err != nil {
|
|
t.Fatalf("group: %v", err)
|
|
}
|
|
sid := ulid.Make().String()
|
|
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
|
ID: sid, HostID: hostID,
|
|
CronExpr: "0 3 * * *", Enabled: true,
|
|
SourceGroupIDs: []string{gid},
|
|
}); err != nil {
|
|
t.Fatalf("schedule: %v", err)
|
|
}
|
|
|
|
payload, err := srv.buildScheduleSetPayload(context.Background(), hostID)
|
|
if err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
if payload.Version == 0 {
|
|
t.Fatalf("version should be > 0, got %d", payload.Version)
|
|
}
|
|
if len(payload.Schedules) != 1 {
|
|
t.Fatalf("schedules: %d", len(payload.Schedules))
|
|
}
|
|
entry := payload.Schedules[0]
|
|
if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled {
|
|
t.Errorf("schedule fields: %+v", entry)
|
|
}
|
|
if len(entry.SourceGroups) != 1 {
|
|
t.Fatalf("groups in schedule: %d", len(entry.SourceGroups))
|
|
}
|
|
g := entry.SourceGroups[0]
|
|
if g.Name != "default" {
|
|
t.Errorf("group name: %s", g.Name)
|
|
}
|
|
if !equalStrings(g.Includes, []string{"/etc", "/home"}) {
|
|
t.Errorf("includes: %v", g.Includes)
|
|
}
|
|
var rp map[string]any
|
|
_ = json.Unmarshal(g.RetentionPolicy, &rp)
|
|
if rp["keep_daily"] != float64(7) {
|
|
t.Errorf("retention: %+v", rp)
|
|
}
|
|
}
|
|
|
|
// ----- per-source-group Run-now -----------------------------------
|
|
|
|
func TestRunSourceGroupOfflineHost(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "offline-host")
|
|
|
|
gid := ulid.Make().String()
|
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
|
|
}); err != nil {
|
|
t.Fatalf("group: %v", err)
|
|
}
|
|
|
|
// JSON path → 503 (host offline).
|
|
req, _ := stdhttp.NewRequest("POST",
|
|
url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
|
|
req.AddCookie(cookie)
|
|
req.Header.Set("Accept", "application/json")
|
|
res, _ := stdhttp.DefaultClient.Do(req)
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusServiceUnavailable {
|
|
t.Errorf("offline: want 503, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRunSourceGroupUnknownGroup(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
cookie := loginAsAdmin(t, st)
|
|
hostID := makeHost(t, st, "noh-host")
|
|
|
|
req, _ := stdhttp.NewRequest("POST",
|
|
url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
|
|
req.AddCookie(cookie)
|
|
req.Header.Set("Accept", "application/json")
|
|
res, _ := stdhttp.DefaultClient.Do(req)
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusNotFound {
|
|
t.Errorf("unknown group: want 404, got %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ----- helpers ----------------------------------------------------
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// keep fmt import live — used for occasional debug.
|
|
var _ = fmt.Sprintf
|
|
var _ = strings.HasPrefix
|