// schedules.go — REST API for /api/hosts/{id}/schedules. // // Slim-shape body: {cron, enabled, source_group_ids[]}. Paths, // excludes, retention, retry, kind, manual — all gone. Those live on // SourceGroup; a schedule is just "fire this cron, run backups for // these groups." Mutations bump host_schedule_version and (best-effort) // push the new set to a connected agent. package http import ( "encoding/json" "errors" stdhttp "net/http" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "github.com/robfig/cron/v3" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // scheduleView is the JSON shape returned by GET. Stable wire format // — UI form binds to it. type scheduleView struct { ID string `json:"id"` HostID string `json:"host_id"` CronExpr string `json:"cron"` Enabled bool `json:"enabled"` SourceGroupIDs []string `json:"source_group_ids"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func toScheduleView(s store.Schedule) scheduleView { ids := s.SourceGroupIDs if ids == nil { ids = []string{} } return scheduleView{ ID: s.ID, HostID: s.HostID, CronExpr: s.CronExpr, Enabled: s.Enabled, SourceGroupIDs: ids, CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, } } // scheduleWriteRequest is the body of POST and PUT. type scheduleWriteRequest struct { CronExpr string `json:"cron"` Enabled bool `json:"enabled"` SourceGroupIDs []string `json:"source_group_ids"` } // cronParser mirrors robfig/cron/v3's New() default; reuse it across // every validate call so we're consistent with what the agent uses. var cronParser = cron.NewParser( cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, ) func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") if hostID == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") return } if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } out := make([]scheduleView, 0, len(rows)) for _, sc := range rows { out = append(out, toScheduleView(sc)) } writeJSON(w, stdhttp.StatusOK, struct { Schedules []scheduleView `json:"schedules"` }{Schedules: out}) } func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") if hostID == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "") return } if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") return } var req scheduleWriteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok { writeJSONError(w, stdhttp.StatusBadRequest, code, msg) return } sc := store.Schedule{ ID: ulid.Make().String(), HostID: hostID, CronExpr: req.CronExpr, Enabled: req.Enabled, SourceGroupIDs: req.SourceGroupIDs, } if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } s.pushScheduleSetAsync(hostID) writeJSON(w, stdhttp.StatusCreated, toScheduleView(sc)) } func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") 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.GetSchedule(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", "") return } var req scheduleWriteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } if code, msg, ok := s.validateScheduleRequest(r, hostID, req); !ok { writeJSONError(w, stdhttp.StatusBadRequest, code, msg) return } sc := store.Schedule{ ID: scheduleID, HostID: hostID, CronExpr: req.CronExpr, Enabled: req.Enabled, SourceGroupIDs: req.SourceGroupIDs, } if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } s.pushScheduleSetAsync(hostID) out, _ := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID) if out != nil { writeJSON(w, stdhttp.StatusOK, toScheduleView(*out)) return } writeJSON(w, stdhttp.StatusOK, toScheduleView(sc)) } func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") 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.pushScheduleSetAsync(hostID) w.WriteHeader(stdhttp.StatusNoContent) } // validateScheduleRequest enforces wire-shape rules: cron must parse, // at least one source group must be attached, and every referenced // group must belong to this host. Returns (code, msg, ok=false) on // failure; ok=true means proceed. func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req scheduleWriteRequest) (string, string, bool) { if req.CronExpr == "" { return "missing_field", "cron is required", false } if _, err := cronParser.Parse(req.CronExpr); err != nil { return "invalid_cron", err.Error(), false } if len(req.SourceGroupIDs) == 0 { return "missing_field", "source_group_ids must contain at least one group", false } // Every referenced group must exist and belong to this host. for _, gid := range req.SourceGroupIDs { g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid) if err != nil || g == nil { return "invalid_group", "source group " + gid + " not found on this host", false } } return "", "", true }