feat(schedules): 每台服务器可设计划任务,按星期和时区定时发命令、重启、停服、开服或备份,重启和备份前可在游戏内提醒
This commit is contained in:
32 files changed
+6011
-27
No files matched your search
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/naming"
|
||||
)
|
||||
|
||||
// scheduleRunTimeout bounds the first step of a run somebody asked for. The
|
||||
// step outlives the request, so a client that hangs up cannot cut a stop off
|
||||
// halfway.
|
||||
const scheduleRunTimeout = 30 * time.Second
|
||||
|
||||
// scheduleServer resolves {name} for the schedule routes and applies their
|
||||
// gate: 400 for a malformed name, 404 for a server that does not exist, 403
|
||||
// for a caller who neither owns it nor is an admin, 503 without a store.
|
||||
func (a *API) scheduleServer(w http.ResponseWriter, r *http.Request) (*ServerRecord, bool) {
|
||||
name := r.PathValue("name")
|
||||
if err := naming.ValidateServerName(name); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err))
|
||||
return nil, false
|
||||
}
|
||||
rec, err := a.Repo.ServerByName(r.Context(), name)
|
||||
if err != nil {
|
||||
a.writeLookupError(w, r, err)
|
||||
return nil, false
|
||||
}
|
||||
if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) {
|
||||
writeError(w, r, errForbidden)
|
||||
return nil, false
|
||||
}
|
||||
if a.Schedules == nil {
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "schedules_unavailable",
|
||||
"scheduled tasks are not configured"))
|
||||
return nil, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// scheduleID parses {id}.
|
||||
func scheduleID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_id", "invalid schedule id"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// writeScheduleError maps the store's schedule errors.
|
||||
func (a *API) writeScheduleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrScheduleRunning):
|
||||
writeError(w, r, newError(http.StatusConflict, "schedule_running",
|
||||
"this schedule is running; try again once the run finishes"))
|
||||
case errors.Is(err, ErrScheduleLimit):
|
||||
writeError(w, r, newError(http.StatusConflict, "schedule_limit",
|
||||
"a server can have at most %d scheduled tasks", maxSchedulesPerServer))
|
||||
default:
|
||||
a.writeLookupError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// auditSchedule records a change to a schedule by the signed-in caller.
|
||||
func (a *API) auditSchedule(r *http.Request, action string, s *Schedule) {
|
||||
p := principalFromContext(r.Context())
|
||||
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: s.Server,
|
||||
Payload: auditPayload(map[string]any{"schedule": s.ID, "action": s.Action})}
|
||||
if p != nil {
|
||||
e.ActorUserID = p.UserID
|
||||
}
|
||||
a.auditEntry(r, e)
|
||||
}
|
||||
|
||||
// handleListSchedules serves GET /servers/{name}/schedules.
|
||||
func (a *API) handleListSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := a.scheduleServer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := a.Schedules.ListSchedules(r.Context(), rec.Name)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []Schedule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"server": rec.Name, "schedules": list, "limit": maxSchedulesPerServer})
|
||||
}
|
||||
|
||||
// handleCreateSchedule serves POST /servers/{name}/schedules. The schedule
|
||||
// belongs to the server's current owner (see Schedule.OwnerID).
|
||||
func (a *API) handleCreateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := a.scheduleServer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in scheduleInput
|
||||
if err := decodeJSON(w, r, &in); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
s := &Schedule{Server: rec.Name, OwnerID: rec.OwnerID, CreatedBy: auditActor(principalFromContext(r.Context()))}
|
||||
if err := in.apply(s); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.NextRunAt = a.firstRun(s)
|
||||
if err := a.Schedules.CreateSchedule(r.Context(), s, maxSchedulesPerServer); err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.auditSchedule(r, "schedule.create", s)
|
||||
writeJSON(w, http.StatusCreated, s)
|
||||
}
|
||||
|
||||
// handleUpdateSchedule serves PUT /servers/{name}/schedules/{id}: new settings,
|
||||
// and the schedule passes to the server's current owner.
|
||||
func (a *API) handleUpdateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := a.scheduleServer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := scheduleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in scheduleInput
|
||||
if err := decodeJSON(w, r, &in); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||
if err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := in.apply(s); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.OwnerID, s.NextRunAt = rec.OwnerID, a.firstRun(s)
|
||||
if err := a.Schedules.UpdateSchedule(r.Context(), s); err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.auditSchedule(r, "schedule.update", s)
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
// handleDeleteSchedule serves DELETE /servers/{name}/schedules/{id}.
|
||||
func (a *API) handleDeleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := a.scheduleServer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := scheduleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||
if err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.Schedules.DeleteSchedule(r.Context(), rec.Name, id); err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.auditSchedule(r, "schedule.delete", s)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRunSchedule serves POST /servers/{name}/schedules/{id}/run: the run
|
||||
// starts now, without the players' warning, and the next scheduled run stays
|
||||
// where it is. The answer is the schedule after the run's first step; a restart
|
||||
// or backup goes on in the background.
|
||||
func (a *API) handleRunSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := a.scheduleServer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := scheduleID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, err := a.Schedules.GetSchedule(r.Context(), rec.Name, id)
|
||||
if err != nil {
|
||||
a.writeScheduleError(w, r, err)
|
||||
return
|
||||
}
|
||||
if s.OwnerID != rec.OwnerID {
|
||||
writeError(w, r, newError(http.StatusConflict, "schedule_stale",
|
||||
"the server has a new owner since this schedule was saved; save it again first"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), scheduleRunTimeout)
|
||||
defer cancel()
|
||||
now := a.now()
|
||||
claimed, err := a.Schedules.ClaimScheduleRun(ctx, id, nil, nil, now)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !claimed {
|
||||
a.writeScheduleError(w, r, ErrScheduleRunning)
|
||||
return
|
||||
}
|
||||
a.auditSchedule(r, "schedule.run_now", s)
|
||||
if err := a.beginRun(ctx, s); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
after, err := a.Schedules.GetSchedule(ctx, rec.Name, id)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, after)
|
||||
}
|
||||
|
||||
// firstRun is the next run of a schedule just saved, nil while it is disabled.
|
||||
func (a *API) firstRun(s *Schedule) *time.Time {
|
||||
if !s.Enabled {
|
||||
return nil
|
||||
}
|
||||
return ptrTime(s.nextRun(a.now()))
|
||||
}
|
||||
Reference in new issue
Block a user