P2-01: schedule schema + CRUD API
The `schedules` table was already laid down in migration 0001; this
slice adds the Go-side data model, store CRUD with atomic version
bumps, and REST endpoints.
* `store.Schedule` + `RetentionPolicy` + `ScheduleOptions` typed
views (the wire form on the agent side keeps retention/options
as raw JSON since the agent just forwards them to restic).
* Store CRUD: CreateSchedule / GetSchedule / ListSchedulesByHost /
UpdateSchedule / DeleteSchedule. Each mutation bumps
`host_schedule_version` atomically in the same tx via UPSERT on
`host_schedule_version`. SetHostAppliedScheduleVersion records
what the agent has confirmed via schedule.ack (P2-02 will use it).
* REST endpoints under /api/hosts/{id}/schedules + /{sid}:
GET (list, with the version envelope so callers can detect
drift), POST (create), PUT (update — kind is immutable), DELETE.
* Validation: cron expressions parse via robfig/cron/v3 (same
parser the agent will use, so anything that validates here will
fire there); kind ∈ {backup, forget, prune, check} (init/unlock
are operator-only one-shot kinds, not schedulable); backup
schedules require ≥1 path; hooks rejected on non-backup kinds
(spec §14.3).
* All mutations audit-logged.
* Tests: store-level CRUD + version-bump invariants; REST happy
path (create→list→update→delete with version progression); REST
validation table covers each rejection code.
newTestServerWithHub now sets BootstrapToken so the schedules
handler tests can use the existing login flow without a parallel
test-server constructor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,10 +36,11 @@ func newTestServerWithHub(t *testing.T) (*Server, string, *store.Store) {
|
||||
aead, _ := crypto.NewAEAD(key)
|
||||
|
||||
deps := Deps{
|
||||
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
|
||||
Store: st,
|
||||
AEAD: aead,
|
||||
Hub: ws.NewHub(),
|
||||
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
|
||||
Store: st,
|
||||
AEAD: aead,
|
||||
Hub: ws.NewHub(),
|
||||
BootstrapToken: "test-token",
|
||||
}
|
||||
s := New(deps)
|
||||
ts := httptest.NewServer(s.srv.Handler)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// scheduleAPI is the JSON shape for /api/hosts/{id}/schedules. We
|
||||
// avoid leaking host_id in the body since it's already in the URL,
|
||||
// and we render booleans + retention as typed JSON rather than
|
||||
// strings so the UI can edit fields directly.
|
||||
type scheduleAPI struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Kind api.JobKind `json:"kind"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
Paths []string `json:"paths"`
|
||||
Excludes []string `json:"excludes"`
|
||||
Tags []string `json:"tags"`
|
||||
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
|
||||
Options store.ScheduleOptions `json:"options"`
|
||||
PreHook string `json:"pre_hook,omitempty"`
|
||||
PostHook string `json:"post_hook,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// listSchedulesResp wraps the array so the response is forward-
|
||||
// compatible (we may want to add a top-level `version` later).
|
||||
type listSchedulesResp struct {
|
||||
Version int64 `json:"version"`
|
||||
Schedules []scheduleAPI `json:"schedules"`
|
||||
}
|
||||
|
||||
// cron parser used for input validation. Standard 5-field syntax
|
||||
// with descriptors (@hourly etc.) — same parser the agent will
|
||||
// run against, so a schedule that validates here will fire there.
|
||||
var cronParser = cron.NewParser(
|
||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||
)
|
||||
|
||||
func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if _, ok := s.requireUser(r); !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_host_id", "")
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
version, err := s.deps.Store.GetHostScheduleVersion(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
out := listSchedulesResp{Version: version, Schedules: make([]scheduleAPI, len(rows))}
|
||||
for i, row := range rows {
|
||||
out.Schedules[i] = toScheduleAPI(row)
|
||||
}
|
||||
writeJSON(w, stdhttp.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
user, ok := s.requireUser(r)
|
||||
if !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_host_id", "")
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req scheduleAPI
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if code, msg := validateSchedule(&req); code != "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
row := store.Schedule{
|
||||
ID: ulid.Make().String(),
|
||||
HostID: hostID,
|
||||
Kind: string(req.Kind),
|
||||
CronExpr: req.CronExpr,
|
||||
Paths: req.Paths,
|
||||
Excludes: req.Excludes,
|
||||
Tags: req.Tags,
|
||||
RetentionPolicy: req.RetentionPolicy,
|
||||
Options: req.Options,
|
||||
PreHook: req.PreHook,
|
||||
PostHook: req.PostHook,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if err := s.deps.Store.CreateSchedule(r.Context(), &row); 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: "schedule.created",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &row.ID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
writeJSON(w, stdhttp.StatusCreated, toScheduleAPI(row))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
user, ok := s.requireUser(r)
|
||||
if !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
if hostID == "" || scheduleID == "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
|
||||
return
|
||||
}
|
||||
existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req scheduleAPI
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
// Kind is immutable; ignore whatever was sent and fix up before
|
||||
// validation so validateSchedule sees the existing kind.
|
||||
req.Kind = api.JobKind(existing.Kind)
|
||||
if code, msg := validateSchedule(&req); code != "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
existing.CronExpr = req.CronExpr
|
||||
existing.Paths = req.Paths
|
||||
existing.Excludes = req.Excludes
|
||||
existing.Tags = req.Tags
|
||||
existing.RetentionPolicy = req.RetentionPolicy
|
||||
existing.Options = req.Options
|
||||
existing.PreHook = req.PreHook
|
||||
existing.PostHook = req.PostHook
|
||||
existing.Enabled = req.Enabled
|
||||
|
||||
if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "")
|
||||
return
|
||||
}
|
||||
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: "schedule.updated",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &existing.ID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
writeJSON(w, stdhttp.StatusOK, toScheduleAPI(*existing))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
user, ok := s.requireUser(r)
|
||||
if !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
if hostID == "" || scheduleID == "" {
|
||||
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONError(w, stdhttp.StatusNotFound, "schedule_not_found", "")
|
||||
return
|
||||
}
|
||||
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: "schedule.deleted",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &scheduleID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
w.WriteHeader(stdhttp.StatusNoContent)
|
||||
}
|
||||
|
||||
// validateSchedule rejects malformed inputs with a stable error code.
|
||||
// Returns ("", "") on success.
|
||||
func validateSchedule(s *scheduleAPI) (code, msg string) {
|
||||
switch s.Kind {
|
||||
case api.JobBackup, api.JobForget, api.JobPrune, api.JobCheck:
|
||||
// ok — valid schedule kinds (init/unlock are operator-driven only).
|
||||
default:
|
||||
return "invalid_kind", "kind must be one of backup|forget|prune|check"
|
||||
}
|
||||
if strings.TrimSpace(s.CronExpr) == "" {
|
||||
return "missing_cron_expr", "cron_expr is required"
|
||||
}
|
||||
if _, err := cronParser.Parse(s.CronExpr); err != nil {
|
||||
return "invalid_cron_expr", err.Error()
|
||||
}
|
||||
if s.Kind == api.JobBackup && len(s.Paths) == 0 {
|
||||
return "missing_paths", "backup schedules require at least one path"
|
||||
}
|
||||
// Hooks are only meaningful on backup schedules (spec §14.3).
|
||||
if s.Kind != api.JobBackup && (s.PreHook != "" || s.PostHook != "") {
|
||||
return "hooks_not_allowed", "pre_hook / post_hook only apply to backup schedules"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func toScheduleAPI(s store.Schedule) scheduleAPI {
|
||||
out := scheduleAPI{
|
||||
ID: s.ID,
|
||||
Kind: api.JobKind(s.Kind),
|
||||
CronExpr: s.CronExpr,
|
||||
Paths: s.Paths,
|
||||
Excludes: s.Excludes,
|
||||
Tags: s.Tags,
|
||||
RetentionPolicy: s.RetentionPolicy,
|
||||
Options: s.Options,
|
||||
PreHook: s.PreHook,
|
||||
PostHook: s.PostHook,
|
||||
Enabled: s.Enabled,
|
||||
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
|
||||
UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"),
|
||||
}
|
||||
if out.Paths == nil {
|
||||
out.Paths = []string{}
|
||||
}
|
||||
if out.Excludes == nil {
|
||||
out.Excludes = []string{}
|
||||
}
|
||||
if out.Tags == nil {
|
||||
out.Tags = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// loginAndCookie bootstraps an admin, logs in, and returns the
|
||||
// session cookie ready to attach to subsequent requests.
|
||||
func loginAndCookie(t *testing.T, url string) *stdhttp.Cookie {
|
||||
t.Helper()
|
||||
bs, _ := json.Marshal(bootstrapRequest{
|
||||
Token: "test-token", Username: "alice", Password: "averylongpassword",
|
||||
})
|
||||
res, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"})
|
||||
res, err = stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusOK {
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
t.Fatalf("login: %d %s", res.StatusCode, got)
|
||||
}
|
||||
for _, c := range res.Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeHTTPHost inserts a host directly via the store so we can hit
|
||||
// the schedule endpoints without dragging in the enrollment flow.
|
||||
func makeHTTPHost(t *testing.T, st *store.Store) string {
|
||||
t.Helper()
|
||||
const id = "01HSCHEDHTTP000000000000Z"
|
||||
if err := st.CreateHost(context.Background(), store.Host{
|
||||
ID: id, Name: "h", OS: "linux", Arch: "amd64",
|
||||
AgentVersion: "dev", ResticVersion: "0.16.0", ProtocolVersion: 1,
|
||||
EnrolledAt: time.Now().UTC(),
|
||||
}, "tokenhash", ""); err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSchedulesAPIHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, url, st := newTestServerWithHub(t)
|
||||
cookie := loginAndCookie(t, url)
|
||||
hostID := makeHTTPHost(t, st)
|
||||
|
||||
doReq := func(method, path string, body any, want int) []byte {
|
||||
t.Helper()
|
||||
var b []byte
|
||||
if body != nil {
|
||||
b, _ = json.Marshal(body)
|
||||
}
|
||||
req, _ := stdhttp.NewRequest(method, url+path, bytes.NewReader(b))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != want {
|
||||
t.Fatalf("%s %s: status %d (want %d) body=%s", method, path, res.StatusCode, want, got)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// Empty list returns version 0.
|
||||
body := doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
||||
var listed listSchedulesResp
|
||||
if err := json.Unmarshal(body, &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if listed.Version != 0 || len(listed.Schedules) != 0 {
|
||||
t.Fatalf("initial list: %+v", listed)
|
||||
}
|
||||
|
||||
// Create.
|
||||
keepLast := 3
|
||||
create := scheduleAPI{
|
||||
Kind: "backup", CronExpr: "0 */6 * * *",
|
||||
Paths: []string{"/etc"},
|
||||
Tags: []string{"nightly"},
|
||||
RetentionPolicy: store.RetentionPolicy{KeepLast: &keepLast},
|
||||
Enabled: true,
|
||||
}
|
||||
body = doReq("POST", "/api/hosts/"+hostID+"/schedules", create, stdhttp.StatusCreated)
|
||||
var created scheduleAPI
|
||||
if err := json.Unmarshal(body, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" || created.CronExpr != create.CronExpr {
|
||||
t.Fatalf("create returned: %+v", created)
|
||||
}
|
||||
|
||||
// Version bumped.
|
||||
body = doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
||||
_ = json.Unmarshal(body, &listed)
|
||||
if listed.Version != 1 {
|
||||
t.Fatalf("version after create: %d", listed.Version)
|
||||
}
|
||||
|
||||
// Update changes the cron expr; kind silently preserved even if request tries otherwise.
|
||||
created.CronExpr = "*/15 * * * *"
|
||||
created.Kind = "prune" // should be ignored
|
||||
body = doReq("PUT", "/api/hosts/"+hostID+"/schedules/"+created.ID, created, stdhttp.StatusOK)
|
||||
var updated scheduleAPI
|
||||
_ = json.Unmarshal(body, &updated)
|
||||
if updated.Kind != "backup" || updated.CronExpr != "*/15 * * * *" {
|
||||
t.Fatalf("update: %+v", updated)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
doReq("DELETE", "/api/hosts/"+hostID+"/schedules/"+created.ID, nil, stdhttp.StatusNoContent)
|
||||
body = doReq("GET", "/api/hosts/"+hostID+"/schedules", nil, stdhttp.StatusOK)
|
||||
_ = json.Unmarshal(body, &listed)
|
||||
if listed.Version != 3 || len(listed.Schedules) != 0 {
|
||||
t.Fatalf("after delete: %+v", listed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulesAPIValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, url, st := newTestServerWithHub(t)
|
||||
cookie := loginAndCookie(t, url)
|
||||
hostID := makeHTTPHost(t, st)
|
||||
|
||||
post := func(s scheduleAPI) (int, []byte) {
|
||||
b, _ := json.Marshal(s)
|
||||
req, _ := stdhttp.NewRequest("POST",
|
||||
url+"/api/hosts/"+hostID+"/schedules", bytes.NewReader(b))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return res.StatusCode, body
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in scheduleAPI
|
||||
want string // expected error code
|
||||
}{
|
||||
{"bad kind", scheduleAPI{Kind: "init", CronExpr: "@hourly", Paths: []string{"/etc"}}, "invalid_kind"},
|
||||
{"missing cron", scheduleAPI{Kind: "backup", Paths: []string{"/etc"}}, "missing_cron_expr"},
|
||||
{"bad cron", scheduleAPI{Kind: "backup", CronExpr: "not a cron", Paths: []string{"/etc"}}, "invalid_cron_expr"},
|
||||
{"backup without paths", scheduleAPI{Kind: "backup", CronExpr: "@hourly"}, "missing_paths"},
|
||||
{"hooks on non-backup", scheduleAPI{Kind: "prune", CronExpr: "@daily", PreHook: "echo hi"}, "hooks_not_allowed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
status, body := post(c.in)
|
||||
if status != stdhttp.StatusBadRequest {
|
||||
t.Fatalf("status %d body=%s", status, body)
|
||||
}
|
||||
var env struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &env)
|
||||
if env.Code != c.want {
|
||||
t.Fatalf("error code: got %q want %q (body=%s)", env.Code, c.want, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,14 @@ func (s *Server) routes(r chi.Router) {
|
||||
// GET returns a redacted view (URL, username, has_password).
|
||||
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
|
||||
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
|
||||
|
||||
// Per-host schedule CRUD. Mutations bump host_schedule_version;
|
||||
// the agent sync path (P2-02) picks up the new version on the
|
||||
// next reconciliation tick.
|
||||
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
|
||||
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
|
||||
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
|
||||
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
|
||||
})
|
||||
|
||||
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
|
||||
|
||||
Reference in New Issue
Block a user