P2 redesign · phase 2: store rewrite — sources, slim schedules, repo maintenance
Go-side data model rebuilt against migration 0008. The fat-Schedule
shape (paths/excludes/tags/retention/manual/kind/options/hooks) is
gone; that surface lives on source_groups now.
* store/types.go
- Schedule slimmed to {id, host_id, cron, enabled, source_group_ids,
timestamps}. SourceGroupIDs populated by Get/List, accepted on
Create/Update so callers pass desired junction state in one shape.
- SourceGroup added: name (= snapshot tag), includes/excludes,
retention_policy, retry_max + retry_backoff_seconds, cached
conflict_dimension.
- HostRepoMaintenance added: forget/prune/check cadences + enabled.
- PendingRun added: offline-retry queue.
- Host loses RepoInitialisedAt; gains BandwidthUpKBps + BandwidthDownKBps.
- RetentionPolicy moves home from "schedule field" to "source group
field" but the type itself + Summary() method unchanged.
* store/sources.go (new) — CRUD + GetByName + ConflictDimension cache.
Group writes bump host_schedule_version; conflict cache writes don't
(server-internal projection, agent doesn't see it).
* store/maintenance.go (new) — CreateDefault is idempotent (INSERT OR
IGNORE). UpdateRepoMaintenance doesn't bump schedule version because
these run on the server's own ticker, not the agent's local cron.
* store/pending.go (new) — Enqueue / DueRunsForRetry / Bump / Delete.
* store/schedules.go — rewritten for slim shape + junction CRUD.
Update wipes the schedule_source_groups junction wholesale and
re-inserts (simpler than diffing). Adds SchedulesUsingGroup for
retention-conflict detection + UI labels.
* store/hosts.go — drops repo_initialised_at scan, adds bandwidth scan.
New SetHostBandwidth helper.
* HTTP layer — temporarily stubbed during this rewrite (501 returns
with redesign_in_progress error code). Phase 3 fills these in
against the new shape:
- schedules.go REST CRUD
- schedule_push.go agent reconciliation
- ui_schedules.go HTML form CRUD
Run-now-per-host + Init-repo handlers in ui_handlers.go also stubbed
— both go away in the new model (Run-now per source group; auto-init
at host enrolment).
* enrollment.go — replaces "seed manual schedule from typed paths"
with "seed default source group + repo-maintenance row." The default
group gets the typed paths as its includes; operator edits later
via Sources tab.
* ws/handler.go — drops the MarkHostRepoInitialised projection (column
is gone; auto-init makes it derivable from latest init job's status).
Tests:
* store: existing schedule test rewritten for slim shape + junction;
new sources_test.go covers source-group CRUD, name uniqueness,
conflict cache, repo-maintenance defaults + idempotent seed,
pending-runs queue lifecycle.
* http: schedules_test.go and schedule_push_test.go deleted — both
exercised the obsolete fat-schedule API. Phase 3 rewrites them
against the new endpoints.
go test ./... green. cmd/server + cmd/agent build. The UI is broken
end-to-end (schedules / sources / repo tabs all hit 501 stubs); Phase 3
restores REST + on-the-wire reconciliation; Phase 4 rewires the UI
templates against the new model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,26 +140,24 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Seed an initial manual schedule from whatever paths the
|
||||
// operator typed into Add-host. The schedule is editable from
|
||||
// the host's Schedules tab; the operator can add automated
|
||||
// schedules alongside it later. We skip this when no paths
|
||||
// were supplied — the host can still enrol; it just can't
|
||||
// back up until the operator adds a schedule.
|
||||
if len(attachments.InitialPaths) > 0 {
|
||||
seed := store.Schedule{
|
||||
ID: ulid.Make().String(),
|
||||
HostID: hostID,
|
||||
Kind: string(api.JobBackup),
|
||||
CronExpr: "",
|
||||
Paths: attachments.InitialPaths,
|
||||
Enabled: true,
|
||||
Manual: true,
|
||||
}
|
||||
if err := s.deps.Store.CreateSchedule(r.Context(), &seed); err != nil {
|
||||
slog.Warn("enrollment: seed manual schedule failed",
|
||||
"host_id", hostID, "err", err)
|
||||
}
|
||||
// Seed the host's "default" source group with whatever paths the
|
||||
// operator typed into Add-host (empty allowed; group is editable
|
||||
// from the Sources tab post-enrol). Also seed the host's
|
||||
// repo-maintenance row with default cadences so forget/prune/check
|
||||
// start ticking on their own. Auto-init dispatch lands in Phase 6
|
||||
// of the redesign.
|
||||
if err := s.deps.Store.CreateSourceGroup(r.Context(), &store.SourceGroup{
|
||||
ID: ulid.Make().String(),
|
||||
HostID: hostID,
|
||||
Name: "default",
|
||||
Includes: attachments.InitialPaths,
|
||||
}); err != nil {
|
||||
slog.Warn("enrollment: seed default source group failed",
|
||||
"host_id", hostID, "err", err)
|
||||
}
|
||||
if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); err != nil {
|
||||
slog.Warn("enrollment: seed repo maintenance failed",
|
||||
"host_id", hostID, "err", err)
|
||||
}
|
||||
|
||||
// Promote the encrypted repo creds onto the freshly-created host
|
||||
|
||||
@@ -2,258 +2,36 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// loadScheduleSetPayload reads the host's current schedule set + the
|
||||
// canonical version into a wire-shape payload. Returns an empty
|
||||
// (but well-formed) payload with version 0 if the host has nothing
|
||||
// scheduled yet — that's still a valid state to push so the agent
|
||||
// drops any stale cron entries from a previous deployment.
|
||||
func (s *Server) loadScheduleSetPayload(ctx context.Context, hostID string) (api.ScheduleSetPayload, error) {
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return api.ScheduleSetPayload{}, err
|
||||
}
|
||||
version, err := s.deps.Store.GetHostScheduleVersion(ctx, hostID)
|
||||
if err != nil {
|
||||
return api.ScheduleSetPayload{}, err
|
||||
}
|
||||
out := api.ScheduleSetPayload{
|
||||
Version: version,
|
||||
Schedules: make([]api.Schedule, 0, len(rows)),
|
||||
}
|
||||
for _, r := range rows {
|
||||
retJSON, _ := json.Marshal(r.RetentionPolicy)
|
||||
optJSON, _ := json.Marshal(r.Options)
|
||||
out.Schedules = append(out.Schedules, api.Schedule{
|
||||
ID: r.ID,
|
||||
Kind: api.JobKind(r.Kind),
|
||||
CronExpr: r.CronExpr,
|
||||
Paths: r.Paths,
|
||||
Excludes: r.Excludes,
|
||||
Tags: r.Tags,
|
||||
RetentionPolicy: retJSON,
|
||||
Options: optJSON,
|
||||
PreHook: r.PreHook,
|
||||
PostHook: r.PostHook,
|
||||
Enabled: r.Enabled,
|
||||
Manual: r.Manual,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pushScheduleSet ships the current schedule list to the agent over
|
||||
// the hub. Caller has already determined the agent is connected.
|
||||
// Errors are logged and returned; the next push (or the next hello)
|
||||
// will retry. Idempotent — sending the same version twice is a
|
||||
// harmless no-op on the agent side.
|
||||
func (s *Server) pushScheduleSet(ctx context.Context, hostID string) error {
|
||||
pl, err := s.loadScheduleSetPayload(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("push schedule.set: load failed", "host_id", hostID, "err", err)
|
||||
return err
|
||||
}
|
||||
env, err := api.Marshal(api.MsgScheduleSet, "", pl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.deps.Hub.Send(sendCtx, hostID, env); err != nil {
|
||||
slog.Warn("push schedule.set: hub send failed",
|
||||
"host_id", hostID, "version", pl.Version, "err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushScheduleSetOnConn is the on-hello flavour: writes directly to
|
||||
// the just-handshaken conn rather than racing through the hub. Used
|
||||
// by onAgentHello so a brand-new connection can't miss an early
|
||||
// push because Register hasn't completed yet.
|
||||
func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) {
|
||||
pl, err := s.loadScheduleSetPayload(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("on-hello: load schedules", "host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
env, err := api.Marshal(api.MsgScheduleSet, "", pl)
|
||||
if err != nil {
|
||||
slog.Error("on-hello: marshal schedule.set", "host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.Send(sendCtx, env); err != nil {
|
||||
slog.Warn("on-hello: send schedule.set",
|
||||
"host_id", hostID, "version", pl.Version, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pushIfConnected dispatches a schedule.set push asynchronously when
|
||||
// the agent is online. Used by CRUD handlers: a missed push is
|
||||
// non-fatal because the next reconnect's on-hello path will catch
|
||||
// the agent up. Decoupled from the request so the operator's HTTP
|
||||
// response doesn't wait on the WS round-trip.
|
||||
func (s *Server) pushScheduleSetAsync(hostID string) {
|
||||
if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = s.pushScheduleSet(ctx, hostID)
|
||||
}()
|
||||
}
|
||||
|
||||
// applyScheduleAck records the version the agent has confirmed via
|
||||
// schedule.ack. Called from the WS dispatcher (wired below). A bad
|
||||
// version (zero, or higher than what we've issued) is logged but
|
||||
// not fatal — the next push will set the agent straight.
|
||||
func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version int64, appliedAt time.Time) {
|
||||
if version <= 0 {
|
||||
return
|
||||
}
|
||||
canonical, err := s.deps.Store.GetHostScheduleVersion(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("schedule.ack: load canonical version",
|
||||
"host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
if version > canonical {
|
||||
slog.Warn("schedule.ack: agent reported version ahead of server",
|
||||
"host_id", hostID, "agent", version, "server", canonical)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.SetHostAppliedScheduleVersion(ctx, hostID, version); err != nil {
|
||||
slog.Warn("schedule.ack: persist applied version",
|
||||
"host_id", hostID, "version", version, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("schedule.ack: applied",
|
||||
"host_id", hostID, "version", version, "applied_at", appliedAt)
|
||||
}
|
||||
|
||||
// dispatchScheduledJob is invoked when the agent reports a local
|
||||
// cron fire via `schedule.fire`. Thin wrapper around the shared
|
||||
// dispatcher; logs and discards the return values since the agent
|
||||
// can't usefully act on them.
|
||||
func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, _ *ws.Conn, scheduleID string, scheduledAt time.Time) {
|
||||
jobID, err := s.dispatchScheduleNow(ctx, hostID, scheduleID, nil)
|
||||
if err != nil {
|
||||
slog.Warn("schedule.fire: dispatch failed",
|
||||
"host_id", hostID, "schedule_id", scheduleID, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("schedule.fire: dispatched",
|
||||
"host_id", hostID, "schedule_id", scheduleID,
|
||||
"job_id", jobID, "scheduled_at", scheduledAt)
|
||||
}
|
||||
|
||||
// dispatchScheduleNow looks up a schedule, builds a CommandRunPayload,
|
||||
// persists a jobs row (actor_kind=schedule, scheduled_id linking
|
||||
// back), and ships MsgCommandRun to the host. Used by both the
|
||||
// agent-driven path (cron fire reaches us as schedule.fire) and the
|
||||
// UI-driven path (operator clicks Run-now on a schedule row).
|
||||
// schedule_push.go — server → agent reconciliation push.
|
||||
//
|
||||
// conn is optional: when set we write directly through it (no race
|
||||
// against an in-flight Register). When nil we fall back to Hub.Send.
|
||||
// Returns the new job_id on success.
|
||||
func (s *Server) dispatchScheduleNow(ctx context.Context, hostID, scheduleID string, conn *ws.Conn) (string, error) {
|
||||
sched, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "", errFmtf("schedule not found")
|
||||
}
|
||||
return "", errFmtf("internal: %s", err)
|
||||
}
|
||||
if !sched.Enabled {
|
||||
return "", errFmtf("schedule is disabled")
|
||||
}
|
||||
// Stubbed during the P2 redesign rewrite. The new wire shape (slim
|
||||
// schedules referencing source groups; groups inline by id at push
|
||||
// time) lands in Phase 3. Until then on-hello and post-CRUD pushes
|
||||
// are no-ops; the agent will keep its existing cron entries (none,
|
||||
// since there are no schedules yet) and the only operator-driven
|
||||
// jobs flow via run-now once the new UI is wired in Phase 4.
|
||||
|
||||
var args []string
|
||||
if sched.Kind == string(api.JobBackup) {
|
||||
args = append(args, sched.Paths...)
|
||||
}
|
||||
|
||||
// forget jobs need the retention policy on the wire — restic
|
||||
// refuses to run without keep-* flags, and the agent doesn't
|
||||
// hold a copy of the schedule (server is the source of truth).
|
||||
var retentionJSON json.RawMessage
|
||||
if sched.Kind == string(api.JobForget) {
|
||||
if sched.RetentionPolicy == (store.RetentionPolicy{}) {
|
||||
return "", errFmtf("schedule has no retention policy — refusing to forget (would delete every snapshot)")
|
||||
}
|
||||
b, err := json.Marshal(sched.RetentionPolicy)
|
||||
if err != nil {
|
||||
return "", errFmtf("marshal retention policy: %s", err)
|
||||
}
|
||||
retentionJSON = b
|
||||
}
|
||||
|
||||
jobID := ulid.Make().String()
|
||||
now := time.Now().UTC()
|
||||
if err := s.deps.Store.CreateJob(ctx, store.Job{
|
||||
ID: jobID,
|
||||
HostID: hostID,
|
||||
Kind: sched.Kind,
|
||||
ScheduledID: &sched.ID,
|
||||
ActorKind: "schedule",
|
||||
ActorID: &sched.ID,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
return "", errFmtf("create job: %s", err)
|
||||
}
|
||||
|
||||
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
|
||||
JobID: jobID,
|
||||
Kind: api.JobKind(sched.Kind),
|
||||
Args: args,
|
||||
RetentionPolicy: retentionJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errFmtf("marshal command.run: %s", err)
|
||||
}
|
||||
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if conn != nil {
|
||||
if err := conn.Send(sendCtx, env); err != nil {
|
||||
return "", errFmtf("send command.run: %s", err)
|
||||
}
|
||||
} else {
|
||||
if err := s.deps.Hub.Send(sendCtx, hostID, env); err != nil {
|
||||
return "", errFmtf("send command.run: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
Actor: "schedule",
|
||||
Action: "job.run_now",
|
||||
TargetKind: ptr("job"),
|
||||
TargetID: &jobID,
|
||||
TS: now,
|
||||
})
|
||||
return jobID, nil
|
||||
func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) {
|
||||
slog.Debug("schedule push: stubbed during P2 redesign", "host_id", hostID)
|
||||
}
|
||||
|
||||
// Compile-time guard that the store actually implements the methods
|
||||
// schedule_push.go calls. Useful when mocking the store in tests.
|
||||
var _ scheduleStore = (*store.Store)(nil)
|
||||
|
||||
type scheduleStore interface {
|
||||
ListSchedulesByHost(ctx context.Context, hostID string) ([]store.Schedule, error)
|
||||
GetHostScheduleVersion(ctx context.Context, hostID string) (int64, error)
|
||||
SetHostAppliedScheduleVersion(ctx context.Context, hostID string, version int64) error
|
||||
func (s *Server) pushScheduleSetAsync(hostID string) {
|
||||
slog.Debug("schedule push async: stubbed during P2 redesign", "host_id", hostID)
|
||||
}
|
||||
|
||||
func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version int64, appliedAt time.Time) {
|
||||
if err := s.deps.Store.SetHostAppliedScheduleVersion(ctx, hostID, version); err != nil {
|
||||
slog.Warn("schedule.ack: persist applied version", "host_id", hostID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *ws.Conn, scheduleID string, scheduledAt time.Time) {
|
||||
slog.Info("schedule.fire: stubbed during P2 redesign",
|
||||
"host_id", hostID, "schedule_id", scheduleID, "scheduled_at", scheduledAt)
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// makePushHost is like makeHTTPHost but mints a known agent token so
|
||||
// the test can dial /ws/agent as the host. Returns (hostID, raw token).
|
||||
func makePushHost(t *testing.T, st *store.Store) (string, string) {
|
||||
t.Helper()
|
||||
const id = "01HSCHEDPUSH00000000000000"
|
||||
tok, _ := auth.NewToken()
|
||||
if err := st.CreateHost(context.Background(), store.Host{
|
||||
ID: id, Name: "ph", OS: "linux", Arch: "amd64",
|
||||
AgentVersion: "dev", ResticVersion: "0.16.0", ProtocolVersion: 1,
|
||||
EnrolledAt: time.Now().UTC(),
|
||||
}, auth.HashToken(tok), ""); err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
return id, tok
|
||||
}
|
||||
|
||||
// readUntilType pumps messages from the WS until one of the wanted
|
||||
// types arrives or ctx times out. Returns the matched envelope.
|
||||
// Useful because the on-hello path may push several messages
|
||||
// (config.update first if creds exist, schedule.set, …).
|
||||
func readUntilType(ctx context.Context, t *testing.T, c *websocket.Conn, want api.MessageType) api.Envelope {
|
||||
t.Helper()
|
||||
for {
|
||||
_, raw, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ws read waiting for %s: %v", want, err)
|
||||
}
|
||||
var env api.Envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("envelope: %v (raw=%s)", err, raw)
|
||||
}
|
||||
t.Logf("recv: type=%s payload=%s", env.Type, env.Payload)
|
||||
if env.Type == want {
|
||||
return env
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulePushOnHelloAndAckRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, url, st := newTestServerWithHub(t)
|
||||
_ = srv
|
||||
cookie := loginAndCookie(t, url)
|
||||
hostID, agentToken := makePushHost(t, st)
|
||||
|
||||
// Pre-populate one schedule so we have something to push.
|
||||
body, _ := json.Marshal(scheduleAPI{
|
||||
Kind: "backup",
|
||||
CronExpr: "@hourly",
|
||||
Paths: []string{"/etc"},
|
||||
Enabled: true,
|
||||
})
|
||||
req, _ := stdhttp.NewRequest("POST", url+"/api/hosts/"+hostID+"/schedules",
|
||||
bytes.NewReader(body))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("create schedule: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusCreated {
|
||||
t.Fatalf("create schedule: %d %s", res.StatusCode, got)
|
||||
}
|
||||
var created scheduleAPI
|
||||
_ = json.Unmarshal(got, &created)
|
||||
|
||||
// Dial the WS as the agent and send hello.
|
||||
wsURL := "ws" + strings.TrimPrefix(url, "http") + "/ws/agent"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + agentToken}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
helloEnv, _ := api.Marshal(api.MsgHello, "", api.HelloPayload{
|
||||
ProtocolVersion: api.CurrentProtocolVersion,
|
||||
AgentVersion: "test", ResticVersion: "test",
|
||||
Hostname: "ph", OS: api.OSLinux, Arch: api.ArchAmd64,
|
||||
})
|
||||
raw, _ := json.Marshal(helloEnv)
|
||||
if err := c.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write hello: %v", err)
|
||||
}
|
||||
|
||||
// Server should push schedule.set (our host has no creds, so the
|
||||
// config.update branch is silently skipped).
|
||||
pushedEnv := readUntilType(ctx, t, c, api.MsgScheduleSet)
|
||||
var pushed api.ScheduleSetPayload
|
||||
if err := pushedEnv.UnmarshalPayload(&pushed); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if pushed.Version != 1 {
|
||||
t.Fatalf("pushed version: got %d, want 1", pushed.Version)
|
||||
}
|
||||
if len(pushed.Schedules) != 1 || pushed.Schedules[0].ID != created.ID {
|
||||
t.Fatalf("pushed schedules: %+v", pushed.Schedules)
|
||||
}
|
||||
if pushed.Schedules[0].CronExpr != "@hourly" || len(pushed.Schedules[0].Paths) != 1 {
|
||||
t.Fatalf("schedule contents: %+v", pushed.Schedules[0])
|
||||
}
|
||||
|
||||
// Ack the version. Server should record it on the host row.
|
||||
ackEnv, _ := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{
|
||||
Version: pushed.Version,
|
||||
AppliedAt: time.Now().UTC(),
|
||||
})
|
||||
raw, _ = json.Marshal(ackEnv)
|
||||
if err := c.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write ack: %v", err)
|
||||
}
|
||||
|
||||
// Wait for applied_schedule_version to flip.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
h, err := st.GetHost(context.Background(), hostID)
|
||||
if err == nil && h.AppliedScheduleVersion == pushed.Version {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
h, _ := st.GetHost(context.Background(), hostID)
|
||||
t.Fatalf("applied_schedule_version did not advance: got %d, want %d",
|
||||
h.AppliedScheduleVersion, pushed.Version)
|
||||
}
|
||||
|
||||
func TestScheduleFireDispatchesCommandRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, url, st := newTestServerWithHub(t)
|
||||
_ = srv
|
||||
cookie := loginAndCookie(t, url)
|
||||
hostID, agentToken := makePushHost(t, st)
|
||||
|
||||
// Pre-create one backup schedule.
|
||||
body, _ := json.Marshal(scheduleAPI{
|
||||
Kind: "backup", CronExpr: "@hourly",
|
||||
Paths: []string{"/etc/hostname"}, Enabled: true,
|
||||
})
|
||||
req, _ := stdhttp.NewRequest("POST",
|
||||
url+"/api/hosts/"+hostID+"/schedules", bytes.NewReader(body))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
var created scheduleAPI
|
||||
_ = json.Unmarshal(got, &created)
|
||||
|
||||
// Connect as the agent.
|
||||
wsURL := "ws" + strings.TrimPrefix(url, "http") + "/ws/agent"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + agentToken}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
helloEnv, _ := api.Marshal(api.MsgHello, "", api.HelloPayload{
|
||||
ProtocolVersion: api.CurrentProtocolVersion,
|
||||
AgentVersion: "test", ResticVersion: "test",
|
||||
Hostname: "ph", OS: api.OSLinux, Arch: api.ArchAmd64,
|
||||
})
|
||||
raw, _ := json.Marshal(helloEnv)
|
||||
_ = c.Write(ctx, websocket.MessageText, raw)
|
||||
|
||||
// Drain the on-hello schedule.set.
|
||||
_ = readUntilType(ctx, t, c, api.MsgScheduleSet)
|
||||
|
||||
// Pretend our local cron just fired this schedule.
|
||||
fireEnv, _ := api.Marshal(api.MsgScheduleFire, "", api.ScheduleFirePayload{
|
||||
ScheduleID: created.ID,
|
||||
ScheduledAt: time.Now().UTC(),
|
||||
})
|
||||
raw, _ = json.Marshal(fireEnv)
|
||||
if err := c.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write fire: %v", err)
|
||||
}
|
||||
|
||||
// Server should respond with command.run.
|
||||
cmdEnv := readUntilType(ctx, t, c, api.MsgCommandRun)
|
||||
var cmd api.CommandRunPayload
|
||||
if err := cmdEnv.UnmarshalPayload(&cmd); err != nil {
|
||||
t.Fatalf("decode command.run: %v", err)
|
||||
}
|
||||
if cmd.JobID == "" || cmd.Kind != api.JobBackup {
|
||||
t.Fatalf("command.run: %+v", cmd)
|
||||
}
|
||||
if len(cmd.Args) != 1 || cmd.Args[0] != "/etc/hostname" {
|
||||
t.Fatalf("command.run args: %+v", cmd.Args)
|
||||
}
|
||||
|
||||
// Verify the job row landed with actor_kind=schedule.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var actorKind, scheduledID string
|
||||
row := st.DB().QueryRowContext(context.Background(),
|
||||
`SELECT actor_kind, COALESCE(scheduled_id,'') FROM jobs WHERE id = ?`,
|
||||
cmd.JobID)
|
||||
if err := row.Scan(&actorKind, &scheduledID); err == nil {
|
||||
if actorKind != "schedule" {
|
||||
t.Fatalf("job actor_kind: %q", actorKind)
|
||||
}
|
||||
if scheduledID != created.ID {
|
||||
t.Fatalf("job scheduled_id: %q want %q", scheduledID, created.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job row %s never landed", cmd.JobID)
|
||||
}
|
||||
|
||||
func TestSchedulePushOnCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, url, st := newTestServerWithHub(t)
|
||||
_ = srv
|
||||
cookie := loginAndCookie(t, url)
|
||||
hostID, agentToken := makePushHost(t, st)
|
||||
|
||||
// Connect first so the CRUD push has somewhere to land.
|
||||
wsURL := "ws" + strings.TrimPrefix(url, "http") + "/ws/agent"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + agentToken}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
helloEnv, _ := api.Marshal(api.MsgHello, "", api.HelloPayload{
|
||||
ProtocolVersion: api.CurrentProtocolVersion,
|
||||
AgentVersion: "test", ResticVersion: "test",
|
||||
Hostname: "ph", OS: api.OSLinux, Arch: api.ArchAmd64,
|
||||
})
|
||||
raw, _ := json.Marshal(helloEnv)
|
||||
_ = c.Write(ctx, websocket.MessageText, raw)
|
||||
|
||||
// Drain the on-hello schedule.set (will be version 0, empty list).
|
||||
first := readUntilType(ctx, t, c, api.MsgScheduleSet)
|
||||
var initial api.ScheduleSetPayload
|
||||
_ = first.UnmarshalPayload(&initial)
|
||||
if initial.Version != 0 || len(initial.Schedules) != 0 {
|
||||
t.Fatalf("initial push: %+v", initial)
|
||||
}
|
||||
|
||||
// Now create a schedule via REST. The handler should fire a
|
||||
// schedule.set push asynchronously.
|
||||
body, _ := json.Marshal(scheduleAPI{
|
||||
Kind: "backup", CronExpr: "*/30 * * * *",
|
||||
Paths: []string{"/var/lib"}, Enabled: true,
|
||||
})
|
||||
req, _ := stdhttp.NewRequest("POST",
|
||||
url+"/api/hosts/"+hostID+"/schedules", bytes.NewReader(body))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusCreated {
|
||||
t.Fatalf("create: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Wait for the pushed schedule.set with version 1.
|
||||
pushed := readUntilType(ctx, t, c, api.MsgScheduleSet)
|
||||
var pl api.ScheduleSetPayload
|
||||
_ = pushed.UnmarshalPayload(&pl)
|
||||
if pl.Version != 1 || len(pl.Schedules) != 1 {
|
||||
t.Fatalf("push after create: %+v", pl)
|
||||
}
|
||||
}
|
||||
@@ -1,310 +1,37 @@
|
||||
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"`
|
||||
// Manual = no cron, fires only when the operator triggers a
|
||||
// run-now. Cron expr is ignored when this is true.
|
||||
Manual bool `json:"manual"`
|
||||
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,
|
||||
)
|
||||
// schedules.go — REST API for /api/hosts/{id}/schedules.
|
||||
//
|
||||
// Stubbed during the P2 redesign data-model rewrite (commit chain).
|
||||
// Phase 2 dropped the fat Schedule shape (paths/excludes/tags/
|
||||
// retention/manual/kind/options/hooks) — the slim Schedule + source
|
||||
// groups model lives in store/. Phase 3 of the redesign will fill in
|
||||
// these handlers against the new shape.
|
||||
//
|
||||
// Returning 501 here keeps the routes addressable; UI calls will
|
||||
// surface the unimplemented state via the toast component until the
|
||||
// new handlers land.
|
||||
|
||||
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)
|
||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
writeJSON(w, stdhttp.StatusCreated, toScheduleAPI(row))
|
||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
writeJSON(w, stdhttp.StatusOK, toScheduleAPI(*existing))
|
||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
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 !s.Manual {
|
||||
if strings.TrimSpace(s.CronExpr) == "" {
|
||||
return "missing_cron_expr", "cron_expr is required (or set manual=true)"
|
||||
}
|
||||
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"
|
||||
}
|
||||
// forget needs at least one keep-* dimension; otherwise restic
|
||||
// would happily delete every snapshot.
|
||||
if s.Kind == api.JobForget && (s.RetentionPolicy == store.RetentionPolicy{}) {
|
||||
return "missing_retention", "forget schedules require at least one Keep-* value"
|
||||
}
|
||||
// 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,
|
||||
Manual: s.Manual,
|
||||
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
|
||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -143,146 +142,23 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIRunBackup is the form-submit twin of POST /api/hosts/{id}/jobs
|
||||
// that the dashboard / host-detail "Run now" buttons call via
|
||||
// hx-post. On success it sets HX-Redirect → /jobs/{job_id} so the
|
||||
// operator lands on the live log viewer for the job they just
|
||||
// kicked off.
|
||||
// handleUIRunBackup and handleUIInitRepo are stubbed during the P2
|
||||
// redesign data-model rewrite. The dashboard per-host Run-now button
|
||||
// is going away (operator clicks into host detail then a per-source-
|
||||
// group Run-now), and Init-repo becomes implicit at host enrolment
|
||||
// (auto-init dispatched server-side). Phase 4 of the redesign wires
|
||||
// the new per-source-group Run-now via /hosts/{id}/source-groups/{gid}/run.
|
||||
|
||||
func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if host.RepoInitialisedAt == nil {
|
||||
stdhttp.Error(w,
|
||||
"this host's repo hasn't been initialised yet — click Initialise repo first",
|
||||
stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pick, err := s.pickRunNowSchedule(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, pick.Paths)
|
||||
if code != "" {
|
||||
stdhttp.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
// HTMX (with hx-post + hx-swap=none) doesn't honour HX-Redirect
|
||||
// when the response itself is a 3xx — fetch follows the redirect
|
||||
// first and the header is lost. Branch on the HX-Request marker
|
||||
// so HTMX gets a 200 + HX-Redirect (client-side window.location
|
||||
// hop), while plain form-post / curl callers get the 303.
|
||||
target := "/jobs/" + res.JobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w,
|
||||
"per-host Run-now is being replaced by per-source-group Run-now — see P2 redesign Phase 4",
|
||||
stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// pickRunNowSchedule chooses which schedule a generic per-host
|
||||
// "Run now" button should dispatch when the operator hasn't picked
|
||||
// one explicitly. Picks in priority order: the host's only enabled
|
||||
// manual schedule, then its only enabled schedule of any kind.
|
||||
// Returns a friendly error if there's nothing to run, or if the
|
||||
// operator needs to disambiguate.
|
||||
func (s *Server) pickRunNowSchedule(ctx context.Context, hostID string) (*store.Schedule, error) {
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, errFmt("internal: %s", err)
|
||||
}
|
||||
enabled := make([]store.Schedule, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Enabled {
|
||||
enabled = append(enabled, r)
|
||||
}
|
||||
}
|
||||
if len(enabled) == 0 {
|
||||
return nil, errFmt("this host has no enabled schedules — add one in the Schedules tab")
|
||||
}
|
||||
manuals := []store.Schedule{}
|
||||
for _, r := range enabled {
|
||||
if r.Manual {
|
||||
manuals = append(manuals, r)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(manuals) == 1:
|
||||
s := manuals[0]
|
||||
return &s, nil
|
||||
case len(enabled) == 1:
|
||||
s := enabled[0]
|
||||
return &s, nil
|
||||
default:
|
||||
return nil, errFmt("this host has %d schedules — pick one from the Schedules tab", len(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
func errFmt(format string, args ...any) error {
|
||||
return errFmtf(format, args...)
|
||||
}
|
||||
|
||||
// handleUIInitRepo dispatches a one-shot `restic init` job for a
|
||||
// host. Surfaced in the run-now panel as a red "Initialise repo"
|
||||
// button when host.repo_initialised_at IS NULL. On success it
|
||||
// redirects to the live log page just like Run-now.
|
||||
func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if hostID == "" {
|
||||
stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobInit, nil)
|
||||
if code != "" {
|
||||
stdhttp.Error(w, msg, status)
|
||||
return
|
||||
}
|
||||
target := "/jobs/" + res.JobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w,
|
||||
"manual Init-repo is being replaced by auto-init at host enrolment — see P2 redesign Phase 6",
|
||||
stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// addHostPage carries the Add-host form state. The result-state
|
||||
|
||||
@@ -1,534 +1,38 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// schedulesListPage carries everything the Schedules tab needs.
|
||||
type schedulesListPage struct {
|
||||
Host store.Host
|
||||
Schedules []store.Schedule
|
||||
Version int64
|
||||
AppliedVersion int64
|
||||
}
|
||||
// ui_schedules.go — HTML form-driven schedule CRUD.
|
||||
//
|
||||
// Stubbed during the P2 redesign template rewrite. Phase 4 of the
|
||||
// redesign rebuilds the schedule editor against the new slim shape
|
||||
// (cron + source-group multi-select + enabled), the source-group
|
||||
// list/edit pages, and the repo-maintenance tab. Until then these
|
||||
// routes return 501; the dashboard's host-row "View →" link is the
|
||||
// only operator entry point that still works.
|
||||
|
||||
// scheduleEditPage drives both the Create form (Schedule.ID empty)
|
||||
// and the Edit form (Schedule populated). Errors come back via Error
|
||||
// to be rendered as a banner; the rest of the fields hold the just-
|
||||
// submitted raw values so a failed POST can re-render with the
|
||||
// operator's typed input still in place.
|
||||
type scheduleEditPage struct {
|
||||
Host store.Host
|
||||
IsNew bool
|
||||
ScheduleID string
|
||||
Error string
|
||||
// Kind is settable on create, immutable on edit. The form's
|
||||
// kind picker is hidden when !IsNew.
|
||||
Kind string
|
||||
// Form values — strings so partial input survives validation
|
||||
// errors (e.g. operator typed "abc" into keep_last).
|
||||
CronExpr string
|
||||
PathsRaw string
|
||||
ExcludesRaw string
|
||||
TagsRaw string
|
||||
KeepLast string
|
||||
KeepHourly string
|
||||
KeepDaily string
|
||||
KeepWeekly string
|
||||
KeepMonthly string
|
||||
KeepYearly string
|
||||
LimitUpKBps string
|
||||
LimitDownKBps string
|
||||
Enabled bool
|
||||
Manual bool
|
||||
}
|
||||
|
||||
// handleUISchedulesList renders the Schedules sub-tab on a host.
|
||||
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
version, _ := s.deps.Store.GetHostScheduleVersion(r.Context(), hostID)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " · schedules · restic-manager"
|
||||
view.Page = schedulesListPage{
|
||||
Host: *host,
|
||||
Schedules: rows,
|
||||
Version: version,
|
||||
AppliedVersion: host.AppliedScheduleVersion,
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "schedules_list", view); err != nil {
|
||||
slog.Error("ui: render schedules_list", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// handleUIScheduleNewGet renders the empty Create form.
|
||||
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "New schedule · " + host.Name
|
||||
view.Page = scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: true,
|
||||
Kind: string(api.JobBackup),
|
||||
CronExpr: "0 3 * * *",
|
||||
Enabled: true,
|
||||
}
|
||||
s.renderScheduleEdit(w, view)
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// handleUIScheduleEditGet renders the Edit form pre-filled from the
|
||||
// existing schedule row.
|
||||
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sched, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: false,
|
||||
ScheduleID: sched.ID,
|
||||
Kind: sched.Kind,
|
||||
CronExpr: sched.CronExpr,
|
||||
PathsRaw: strings.Join(sched.Paths, "\n"),
|
||||
ExcludesRaw: strings.Join(sched.Excludes, "\n"),
|
||||
TagsRaw: strings.Join(sched.Tags, ", "),
|
||||
Enabled: sched.Enabled,
|
||||
Manual: sched.Manual,
|
||||
}
|
||||
page.KeepLast = intStringPtr(sched.RetentionPolicy.KeepLast)
|
||||
page.KeepHourly = intStringPtr(sched.RetentionPolicy.KeepHourly)
|
||||
page.KeepDaily = intStringPtr(sched.RetentionPolicy.KeepDaily)
|
||||
page.KeepWeekly = intStringPtr(sched.RetentionPolicy.KeepWeekly)
|
||||
page.KeepMonthly = intStringPtr(sched.RetentionPolicy.KeepMonthly)
|
||||
page.KeepYearly = intStringPtr(sched.RetentionPolicy.KeepYearly)
|
||||
page.LimitUpKBps = intStringPtr(sched.Options.LimitUploadKBps)
|
||||
page.LimitDownKBps = intStringPtr(sched.Options.LimitDownloadKBps)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Edit schedule · " + host.Name
|
||||
view.Page = page
|
||||
s.renderScheduleEdit(w, view)
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// handleUIScheduleSave handles POST for both create and update. The
|
||||
// edit form posts to /hosts/{id}/schedules/new (for create) or
|
||||
// /hosts/{id}/schedules/{sid}/edit (for update); we branch on whether
|
||||
// {sid} is present in the route params.
|
||||
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil || storeUser == nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page := scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: scheduleID == "",
|
||||
ScheduleID: scheduleID,
|
||||
Kind: strings.TrimSpace(r.PostForm.Get("kind")),
|
||||
CronExpr: strings.TrimSpace(r.PostForm.Get("cron_expr")),
|
||||
PathsRaw: r.PostForm.Get("paths"),
|
||||
ExcludesRaw: r.PostForm.Get("excludes"),
|
||||
TagsRaw: strings.TrimSpace(r.PostForm.Get("tags")),
|
||||
KeepLast: strings.TrimSpace(r.PostForm.Get("keep_last")),
|
||||
KeepHourly: strings.TrimSpace(r.PostForm.Get("keep_hourly")),
|
||||
KeepDaily: strings.TrimSpace(r.PostForm.Get("keep_daily")),
|
||||
KeepWeekly: strings.TrimSpace(r.PostForm.Get("keep_weekly")),
|
||||
KeepMonthly: strings.TrimSpace(r.PostForm.Get("keep_monthly")),
|
||||
KeepYearly: strings.TrimSpace(r.PostForm.Get("keep_yearly")),
|
||||
LimitUpKBps: strings.TrimSpace(r.PostForm.Get("limit_up_kbps")),
|
||||
LimitDownKBps: strings.TrimSpace(r.PostForm.Get("limit_down_kbps")),
|
||||
Enabled: r.PostForm.Get("enabled") == "on",
|
||||
Manual: r.PostForm.Get("manual") == "on",
|
||||
}
|
||||
// Kind is immutable on edit — use the existing schedule's kind
|
||||
// regardless of what the form submitted.
|
||||
if !page.IsNew {
|
||||
if existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID); err == nil {
|
||||
page.Kind = existing.Kind
|
||||
}
|
||||
}
|
||||
if page.Kind == "" {
|
||||
page.Kind = string(api.JobBackup)
|
||||
}
|
||||
|
||||
// Convert the raw form values into store-shape data, surfacing
|
||||
// the first parse error as a banner.
|
||||
paths := splitPaths(page.PathsRaw)
|
||||
excludes := splitPaths(page.ExcludesRaw)
|
||||
tags := splitCSV(page.TagsRaw)
|
||||
|
||||
retention, err := parseRetention(page)
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
options, err := parseOptions(page)
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate against the same rules the JSON API uses. Manual
|
||||
// schedules skip the cron-expr requirement; forget schedules
|
||||
// require a non-empty retention policy. Other validation
|
||||
// (kind in allowed set, paths required for backup, hooks
|
||||
// rejected on non-backup) lives in validateSchedule.
|
||||
apiShape := scheduleAPI{
|
||||
Kind: api.JobKind(page.Kind),
|
||||
CronExpr: page.CronExpr,
|
||||
Paths: paths,
|
||||
Manual: page.Manual,
|
||||
RetentionPolicy: retention,
|
||||
}
|
||||
if code, msg := validateSchedule(&apiShape); code != "" {
|
||||
page.Error = uiErrorMessage(code, msg)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
|
||||
if page.IsNew {
|
||||
row := store.Schedule{
|
||||
ID: ulid.Make().String(),
|
||||
HostID: hostID,
|
||||
Kind: page.Kind,
|
||||
CronExpr: page.CronExpr,
|
||||
Paths: paths,
|
||||
Excludes: excludes,
|
||||
Tags: tags,
|
||||
RetentionPolicy: retention,
|
||||
Options: options,
|
||||
Enabled: page.Enabled,
|
||||
Manual: page.Manual,
|
||||
}
|
||||
if err := s.deps.Store.CreateSchedule(r.Context(), &row); err != nil {
|
||||
page.Error = "Couldn't save schedule — see server log."
|
||||
slog.Error("ui schedule create", "err", err)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.created",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &row.ID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
} else {
|
||||
existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
existing.CronExpr = page.CronExpr
|
||||
existing.Paths = paths
|
||||
existing.Excludes = excludes
|
||||
existing.Tags = tags
|
||||
existing.RetentionPolicy = retention
|
||||
existing.Options = options
|
||||
existing.Enabled = page.Enabled
|
||||
existing.Manual = page.Manual
|
||||
if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil {
|
||||
page.Error = "Couldn't save schedule — see server log."
|
||||
slog.Error("ui schedule update", "err", err)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.updated",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &scheduleID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
}
|
||||
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// handleUIScheduleRun is the POST target of per-schedule Run-now
|
||||
// buttons. Reuses dispatchScheduledJob (the same code path used by
|
||||
// the agent's local cron firing) so manual + automated runs flow
|
||||
// through identical job lifecycle. Sets HX-Redirect to the live
|
||||
// log on success.
|
||||
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !s.deps.Hub.Connected(hostID) {
|
||||
stdhttp.Error(w, "agent is offline", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = host
|
||||
jobID, err := s.dispatchScheduleNow(r.Context(), hostID, scheduleID, nil)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target := "/jobs/" + jobID
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", target)
|
||||
w.WriteHeader(stdhttp.StatusOK)
|
||||
return
|
||||
}
|
||||
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIScheduleDelete is the POST target of the Delete buttons on
|
||||
// the list view. Confirm-then-redirect; no AJAX.
|
||||
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil || storeUser == nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.deleted",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &scheduleID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func (s *Server) renderScheduleEdit(w stdhttp.ResponseWriter, view ui.ViewData) {
|
||||
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
|
||||
slog.Error("ui: render schedule_edit", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderEditPage(w stdhttp.ResponseWriter, u *ui.User, page scheduleEditPage) {
|
||||
view := s.baseView(u, "dashboard")
|
||||
if page.IsNew {
|
||||
view.Title = "New schedule · " + page.Host.Name
|
||||
} else {
|
||||
view.Title = "Edit schedule · " + page.Host.Name
|
||||
}
|
||||
view.Page = page
|
||||
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
||||
s.renderScheduleEdit(w, view)
|
||||
}
|
||||
|
||||
// ----- helpers --------------------------------------------------------
|
||||
|
||||
// splitCSV parses comma-separated values into a clean []string —
|
||||
// leading/trailing whitespace trimmed, blanks dropped.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseRetention(p scheduleEditPage) (store.RetentionPolicy, error) {
|
||||
var r store.RetentionPolicy
|
||||
for _, f := range []struct {
|
||||
raw string
|
||||
dest **int
|
||||
name string
|
||||
}{
|
||||
{p.KeepLast, &r.KeepLast, "keep last"},
|
||||
{p.KeepHourly, &r.KeepHourly, "keep hourly"},
|
||||
{p.KeepDaily, &r.KeepDaily, "keep daily"},
|
||||
{p.KeepWeekly, &r.KeepWeekly, "keep weekly"},
|
||||
{p.KeepMonthly, &r.KeepMonthly, "keep monthly"},
|
||||
{p.KeepYearly, &r.KeepYearly, "keep yearly"},
|
||||
} {
|
||||
v, err := parsePosInt(f.raw)
|
||||
if err != nil {
|
||||
return r, errFmtf("%s: %s", f.name, err)
|
||||
}
|
||||
*f.dest = v
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func parseOptions(p scheduleEditPage) (store.ScheduleOptions, error) {
|
||||
var o store.ScheduleOptions
|
||||
up, err := parsePosInt(p.LimitUpKBps)
|
||||
if err != nil {
|
||||
return o, errFmtf("limit upload: %s", err)
|
||||
}
|
||||
o.LimitUploadKBps = up
|
||||
down, err := parsePosInt(p.LimitDownKBps)
|
||||
if err != nil {
|
||||
return o, errFmtf("limit download: %s", err)
|
||||
}
|
||||
o.LimitDownloadKBps = down
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// parsePosInt turns a possibly-empty string into *int. Empty → nil
|
||||
// (no value). Non-empty must parse as a positive int.
|
||||
func parsePosInt(raw string) (*int, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return nil, errFmtf("must be a whole number")
|
||||
}
|
||||
if v < 0 {
|
||||
return nil, errFmtf("must be non-negative")
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func intStringPtr(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
// uiErrorMessage maps the JSON-API validation codes to operator-
|
||||
// friendly banner text.
|
||||
func uiErrorMessage(code, msg string) string {
|
||||
switch code {
|
||||
case "missing_cron_expr":
|
||||
return "Cron expression is required."
|
||||
case "invalid_cron_expr":
|
||||
return "Cron expression doesn't parse: " + msg
|
||||
case "missing_paths":
|
||||
return "At least one backup path is required (one per line)."
|
||||
case "missing_retention":
|
||||
return "Forget schedules need at least one Keep-* value, otherwise restic would delete every snapshot."
|
||||
case "invalid_kind":
|
||||
return "Unsupported schedule kind."
|
||||
default:
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// errFmtf wraps fmt.Errorf so the validators read consistently.
|
||||
func errFmtf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
stdhttp.Error(w, "schedules UI is being rebuilt — see P2 redesign Phase 4", stdhttp.StatusNotImplemented)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user