P2R-01: REST + WS rewire against the slim shape
Schedules CRUD now takes {cron, enabled, source_group_ids[]} with cron
parsed via robfig/cron/v3 and group membership scoped to the host.
New source-groups CRUD lives at /api/hosts/{id}/source-groups; delete
refuses with 409 if any schedule still references the group, returning
the schedule list so the UI can prompt 'remove from these schedules
first.' Repo-maintenance GET/PUT manages forget/prune/check cadences
on host_repo_maintenance — no version bump, the server-side ticker
(P2R-06) drives execution.
Per-source-group Run-now (POST /hosts/{id}/source-groups/{gid}/run)
resolves the group's includes/excludes/retention/tag and dispatches a
backup command.run with the new structured CommandRunPayload fields
(Includes/Excludes/Tag). Old per-host /hosts/{id}/run-backup and
/hosts/{id}/init-repo return 410 Gone with a redirect message.
schedule_push.go is rebuilt: buildScheduleSetPayload assembles the
slim wire shape, pushScheduleSetOnConn ships it during the on-hello
window, pushScheduleSetAsync fires after every CRUD mutation, and
dispatchScheduledJob handles agent schedule.fire by iterating the
schedule's source groups and dispatching one backup per group with
actor_kind=schedule and scheduled_id pointing at the schedule.
Auto-init at first WS connect: when the host has repo creds bound and
no init job in its history, server dispatches restic init. Restic's
'config file already exists' soft-success means re-runs against an
existing repo no-op; we don't auto-retry on failure (operator triggers
re-init manually via the danger zone in P2R-09).
api.Schedule drops Kind/Paths/Excludes/Tags/RetentionPolicy/Manual etc.
in favour of {id, cron, enabled, source_groups: [...]}. The agent
scheduler stops checking sch.Manual; cmd/agent's backup dispatch reads
Includes/Excludes/Tag instead of Args.
Tests cover the new HTTP surface end-to-end: source-groups CRUD with
in-use refusal, schedule validation (bad cron / missing groups /
foreign group), repo-maintenance auto-seed and validation, the 410
route, and buildScheduleSetPayload's wire-shape correctness. Full
suite passes; smoke env exercises auto-init dispatch on hello,
async push after schedule create, and per-source-group Run-now
landing the right paths/excludes/tag at the agent.
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
Project-specific rules for Claude when working in this repo.
|
Project-specific rules for Claude when working in this repo.
|
||||||
|
|
||||||
|
## No `Co-Authored-By` trailers on commits
|
||||||
|
|
||||||
|
Don't add `Co-Authored-By: Claude ...` (or any other co-author
|
||||||
|
trailer) to commit messages in this repo. The README will make it
|
||||||
|
plain that the project is heavily spec-coded, so per-commit
|
||||||
|
attribution is just noise.
|
||||||
|
|
||||||
## After building a new binary, also stage it for the smoke env
|
## After building a new binary, also stage it for the smoke env
|
||||||
|
|
||||||
The smoke / dev environment runs the server out of `bin/` directly,
|
The smoke / dev environment runs the server out of `bin/` directly,
|
||||||
|
|||||||
+14
-4
@@ -260,12 +260,22 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
|
|||||||
|
|
||||||
switch p.Kind {
|
switch p.Kind {
|
||||||
case api.JobBackup:
|
case api.JobBackup:
|
||||||
// Agent.Args carries [paths...]. Excludes/tags are not yet
|
// Includes/Excludes/Tag come from the source group resolved
|
||||||
// surfaced over the wire; they come with P2 schedule support.
|
// server-side. Args is preserved for backwards compatibility:
|
||||||
|
// if the server sends only Args (older shape) we fall back to
|
||||||
|
// treating it as the paths list with no tag.
|
||||||
|
paths := p.Includes
|
||||||
|
if len(paths) == 0 {
|
||||||
|
paths = p.Args
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
if p.Tag != "" {
|
||||||
|
tags = []string{p.Tag}
|
||||||
|
}
|
||||||
slog.Info("agent: accepting backup job",
|
slog.Info("agent: accepting backup job",
|
||||||
"job_id", p.JobID, "paths", p.Args)
|
"job_id", p.JobID, "paths", paths, "excludes", p.Excludes, "tag", p.Tag)
|
||||||
go func() {
|
go func() {
|
||||||
if err := r.RunBackup(ctx, p.JobID, p.Args, nil, nil); err != nil {
|
if err := r.RunBackup(ctx, p.JobID, paths, p.Excludes, tags); err != nil {
|
||||||
slog.Warn("agent: backup job failed", "job_id", p.JobID, "err", err)
|
slog.Warn("agent: backup job failed", "job_id", p.JobID, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,13 +88,6 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) {
|
|||||||
if !sch.Enabled {
|
if !sch.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Manual schedules carry paths/retention/etc. but have no
|
|
||||||
// cron — they only fire via operator-driven run-now (which
|
|
||||||
// the server resolves directly via dispatchScheduledJob).
|
|
||||||
// Skip without warning: they're a normal data shape.
|
|
||||||
if sch.Manual {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Capture by value so the closure doesn't share id across iters.
|
// Capture by value so the closure doesn't share id across iters.
|
||||||
entry := sch
|
entry := sch
|
||||||
_, err := c.AddFunc(entry.CronExpr, func() {
|
_, err := c.AddFunc(entry.CronExpr, func() {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestApplyEmitsAck(t *testing.T) {
|
|||||||
s.Apply(api.ScheduleSetPayload{
|
s.Apply(api.ScheduleSetPayload{
|
||||||
Version: 7,
|
Version: 7,
|
||||||
Schedules: []api.Schedule{
|
Schedules: []api.Schedule{
|
||||||
{ID: "s1", Kind: api.JobBackup, CronExpr: "@hourly", Enabled: true},
|
{ID: "s1", CronExpr: "@hourly", Enabled: true},
|
||||||
},
|
},
|
||||||
}, tx)
|
}, tx)
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func TestApplyTickFiresScheduleFire(t *testing.T) {
|
|||||||
s.Apply(api.ScheduleSetPayload{
|
s.Apply(api.ScheduleSetPayload{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
Schedules: []api.Schedule{
|
Schedules: []api.Schedule{
|
||||||
{ID: "every-second", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true},
|
{ID: "every-second", CronExpr: "@every 1s", Enabled: true},
|
||||||
},
|
},
|
||||||
}, tx)
|
}, tx)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func TestApplyDisabledEntriesSkipped(t *testing.T) {
|
|||||||
s.Apply(api.ScheduleSetPayload{
|
s.Apply(api.ScheduleSetPayload{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
Schedules: []api.Schedule{
|
Schedules: []api.Schedule{
|
||||||
{ID: "off", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: false},
|
{ID: "off", CronExpr: "@every 1s", Enabled: false},
|
||||||
},
|
},
|
||||||
}, tx)
|
}, tx)
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ func TestApplyReplacesPriorState(t *testing.T) {
|
|||||||
s.Apply(api.ScheduleSetPayload{
|
s.Apply(api.ScheduleSetPayload{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
Schedules: []api.Schedule{
|
Schedules: []api.Schedule{
|
||||||
{ID: "old", Kind: api.JobBackup, CronExpr: "@every 1s", Enabled: true},
|
{ID: "old", CronExpr: "@every 1s", Enabled: true},
|
||||||
},
|
},
|
||||||
}, tx)
|
}, tx)
|
||||||
|
|
||||||
|
|||||||
+40
-22
@@ -66,13 +66,25 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CommandRunPayload is the server → agent dispatch for a run-now job.
|
// CommandRunPayload is the server → agent dispatch for a run-now job.
|
||||||
// RetentionPolicy is populated for kind=forget jobs (raw JSON so the
|
//
|
||||||
// agent doesn't need to share the typed struct definition with the
|
// For kind=backup, Includes/Excludes/Tag are populated from the source
|
||||||
// server's store package).
|
// group the operator (or schedule) targeted; the agent runs one restic
|
||||||
|
// backup invocation per command.run, tagging the snapshot with Tag (=
|
||||||
|
// the source group's name) so retention can target it later via
|
||||||
|
// `restic forget --tag`.
|
||||||
|
//
|
||||||
|
// For kind=forget, RetentionPolicy is the typed keep-* set as raw JSON
|
||||||
|
// (the agent doesn't share the store package's typed struct).
|
||||||
|
//
|
||||||
|
// Args is preserved as a generic free-form slice for kinds that don't
|
||||||
|
// fit the structured fields (e.g. unlock takes none; init takes none).
|
||||||
type CommandRunPayload struct {
|
type CommandRunPayload struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
Kind JobKind `json:"kind"`
|
Kind JobKind `json:"kind"`
|
||||||
Args []string `json:"args,omitempty"`
|
Args []string `json:"args,omitempty"`
|
||||||
|
Includes []string `json:"includes,omitempty"`
|
||||||
|
Excludes []string `json:"excludes,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,30 +183,36 @@ type RepoStatsPayload struct {
|
|||||||
LockState string `json:"lock_state"` // locked|unlocked
|
LockState string `json:"lock_state"` // locked|unlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule is the agent-facing view of a Schedule row. (Server-side
|
// Schedule is the agent-facing view of a slim Schedule row plus its
|
||||||
// CRUD shapes live in the http handlers; this is what gets pushed.)
|
// resolved bundle of source groups. The agent's cron only needs to know
|
||||||
|
// when to fire (CronExpr + Enabled) and which schedule fired (ID); the
|
||||||
|
// SourceGroups are carried for forensic logs and so a future agent that
|
||||||
|
// elects to dispatch jobs locally has the data, but the server-side
|
||||||
|
// dispatch path uses the schedule's group list directly. Manual
|
||||||
|
// schedules are gone — Run-now targets a source group, not a schedule.
|
||||||
type Schedule struct {
|
type Schedule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Kind JobKind `json:"kind"`
|
CronExpr string `json:"cron_expr"`
|
||||||
CronExpr string `json:"cron_expr"`
|
Enabled bool `json:"enabled"`
|
||||||
Paths []string `json:"paths,omitempty"`
|
SourceGroups []ScheduleSourceGroup `json:"source_groups,omitempty"`
|
||||||
Excludes []string `json:"excludes,omitempty"`
|
}
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
// ScheduleSourceGroup is the resolved-at-push-time view of a source
|
||||||
Options json.RawMessage `json:"options,omitempty"`
|
// group attached to a schedule. The agent doesn't need source_group_id
|
||||||
PreHook string `json:"pre_hook,omitempty"`
|
// — Name is the snapshot tag and is unique per host.
|
||||||
PostHook string `json:"post_hook,omitempty"`
|
type ScheduleSourceGroup struct {
|
||||||
Enabled bool `json:"enabled"`
|
Name string `json:"name"`
|
||||||
// Manual schedules are not added to the agent's local cron — they
|
Includes []string `json:"includes,omitempty"`
|
||||||
// fire only when the operator clicks a Run-now button. The agent
|
Excludes []string `json:"excludes,omitempty"`
|
||||||
// can ignore them entirely; we ship them in the payload only so
|
RetentionPolicy json.RawMessage `json:"retention_policy,omitempty"`
|
||||||
// the operator can edit them on a still-disconnected agent.
|
RetryMax int `json:"retry_max,omitempty"`
|
||||||
Manual bool `json:"manual,omitempty"`
|
RetryBackoffSeconds int `json:"retry_backoff_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleSetPayload — server pushes the full canonical schedule list
|
// ScheduleSetPayload — server pushes the full canonical schedule list
|
||||||
// for a host. Agent reconciles its local cron and replies with
|
// for a host. Agent reconciles its local cron and replies with
|
||||||
// ScheduleAckPayload carrying the same Version.
|
// ScheduleAckPayload carrying the same Version. An empty Schedules
|
||||||
|
// list is a valid push that disables every cron entry.
|
||||||
type ScheduleSetPayload struct {
|
type ScheduleSetPayload struct {
|
||||||
Version int64 `json:"version"`
|
Version int64 `json:"version"`
|
||||||
Schedules []Schedule `json:"schedules"`
|
Schedules []Schedule `json:"schedules"`
|
||||||
|
|||||||
@@ -199,6 +199,67 @@ func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn)
|
|||||||
// drop any cron entries left over from a previous deployment.
|
// drop any cron entries left over from a previous deployment.
|
||||||
// Always runs, even when the host has no repo credentials yet.
|
// Always runs, even when the host has no repo credentials yet.
|
||||||
s.pushScheduleSetOnConn(ctx, hostID, conn)
|
s.pushScheduleSetOnConn(ctx, hostID, conn)
|
||||||
|
// Auto-init the repo if we've never landed a successful init job
|
||||||
|
// against this host. Restic treats "config file already exists"
|
||||||
|
// as a soft success, so re-enrolment against a populated repo
|
||||||
|
// just no-ops. Skipped silently when the host has no creds yet —
|
||||||
|
// the next hello after the operator binds creds will dispatch.
|
||||||
|
s.maybeAutoInit(ctx, hostID, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeAutoInit dispatches a `restic init` job iff the host has no
|
||||||
|
// successful init in its history AND repo creds are bound (without
|
||||||
|
// them the runner can't talk to the repo). We rely on Restic's
|
||||||
|
// idempotent init for re-runs.
|
||||||
|
func (s *Server) maybeAutoInit(ctx context.Context, hostID string, conn *ws.Conn) {
|
||||||
|
if _, err := s.deps.Store.GetHostCredentials(ctx, hostID); err != nil {
|
||||||
|
// No creds bound yet — operator hasn't supplied them. The next
|
||||||
|
// hello after creds land will pick this up.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
already, err := s.deps.Store.HasJobOfKind(ctx, hostID, string(api.JobInit))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("auto-init: check job history", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if already {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobID := ulid.Make().String()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := s.deps.Store.CreateJob(ctx, store.Job{
|
||||||
|
ID: jobID,
|
||||||
|
HostID: hostID,
|
||||||
|
Kind: string(api.JobInit),
|
||||||
|
ActorKind: "system",
|
||||||
|
CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("auto-init: persist job", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
|
||||||
|
JobID: jobID,
|
||||||
|
Kind: api.JobInit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("auto-init: marshal command.run", "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("auto-init: send command.run", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
|
||||||
|
ID: ulid.Make().String(),
|
||||||
|
Actor: "system",
|
||||||
|
Action: "host.auto_init",
|
||||||
|
TargetKind: ptr("host"),
|
||||||
|
TargetID: &hostID,
|
||||||
|
TS: now,
|
||||||
|
})
|
||||||
|
slog.Info("auto-init: dispatched", "host_id", hostID, "job_id", jobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushRepoCredsOnHello loads + decrypts + sends the host's repo
|
// pushRepoCredsOnHello loads + decrypts + sends the host's repo
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||||||
// flash banner + redirect.
|
// flash banner + redirect.
|
||||||
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
||||||
hostID string, kind api.JobKind, args []string,
|
hostID string, kind api.JobKind, args []string,
|
||||||
|
) (res runNowResponse, status int, code, msg string) {
|
||||||
|
return s.dispatchJobWithPayload(ctx, user, hostID, kind, api.CommandRunPayload{
|
||||||
|
Kind: kind,
|
||||||
|
Args: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchJobWithPayload is dispatchJob's variant that lets callers
|
||||||
|
// fill in structured fields (Includes/Excludes/Tag/RetentionPolicy)
|
||||||
|
// — used by the per-source-group Run-now path. JobID is filled in
|
||||||
|
// here; callers leave it zero on the input payload.
|
||||||
|
func (s *Server) dispatchJobWithPayload(ctx context.Context, user *store.User,
|
||||||
|
hostID string, kind api.JobKind, payload api.CommandRunPayload,
|
||||||
) (res runNowResponse, status int, code, msg string) {
|
) (res runNowResponse, status int, code, msg string) {
|
||||||
if !validJobKind(kind) {
|
if !validJobKind(kind) {
|
||||||
return res, stdhttp.StatusBadRequest, "invalid_kind",
|
return res, stdhttp.StatusBadRequest, "invalid_kind",
|
||||||
@@ -80,22 +93,26 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
|||||||
|
|
||||||
jobID := ulid.Make().String()
|
jobID := ulid.Make().String()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
var actorID *string
|
||||||
|
actor := "system"
|
||||||
|
if user != nil {
|
||||||
|
actor = "user"
|
||||||
|
actorID = &user.ID
|
||||||
|
}
|
||||||
if err := s.deps.Store.CreateJob(ctx, store.Job{
|
if err := s.deps.Store.CreateJob(ctx, store.Job{
|
||||||
ID: jobID,
|
ID: jobID,
|
||||||
HostID: host.ID,
|
HostID: host.ID,
|
||||||
Kind: string(kind),
|
Kind: string(kind),
|
||||||
ActorKind: "user",
|
ActorKind: actor,
|
||||||
ActorID: &user.ID,
|
ActorID: actorID,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return res, stdhttp.StatusInternalServerError, "internal", ""
|
return res, stdhttp.StatusInternalServerError, "internal", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
|
payload.JobID = jobID
|
||||||
JobID: jobID,
|
payload.Kind = kind
|
||||||
Kind: kind,
|
env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
|
||||||
Args: args,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, stdhttp.StatusInternalServerError, "internal", ""
|
return res, stdhttp.StatusInternalServerError, "internal", ""
|
||||||
}
|
}
|
||||||
@@ -105,8 +122,8 @@ func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
|||||||
|
|
||||||
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
|
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
|
||||||
ID: ulid.Make().String(),
|
ID: ulid.Make().String(),
|
||||||
UserID: &user.ID,
|
UserID: actorID,
|
||||||
Actor: "user",
|
Actor: actor,
|
||||||
Action: "job.run_now",
|
Action: "job.run_now",
|
||||||
TargetKind: ptr("job"),
|
TargetKind: ptr("job"),
|
||||||
TargetID: &jobID,
|
TargetID: &jobID,
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// p2r01_test.go — HTTP-level coverage for the slim-shape REST surface
|
||||||
|
// landed in P2R-01: schedules, source-groups, repo-maintenance, the
|
||||||
|
// per-source-group Run-now endpoint, schedule_push reconciliation,
|
||||||
|
// and auto-init at hello.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginAsAdmin creates an admin user + a session in the store and
|
||||||
|
// returns a cookie ready to attach to outgoing requests.
|
||||||
|
func loginAsAdmin(t *testing.T, st *store.Store) *stdhttp.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := ulid.Make().String()
|
||||||
|
hash, _ := auth.HashPassword("very-long-test-password")
|
||||||
|
if err := st.CreateUser(ctx, store.User{
|
||||||
|
ID: uid, Username: "tester-" + uid[:6],
|
||||||
|
PasswordHash: hash, Role: store.RoleAdmin,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
tok, _ := auth.NewToken()
|
||||||
|
if err := st.CreateSession(ctx, store.Session{
|
||||||
|
UserID: uid,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour).UTC(),
|
||||||
|
}, auth.HashToken(tok)); err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
return &stdhttp.Cookie{Name: sessionCookieName, Value: tok}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeHost inserts a minimal Host row directly via the store. Used by
|
||||||
|
// HTTP-level tests that don't want to go through the full enrollment
|
||||||
|
// path. Returns the host id.
|
||||||
|
func makeHost(t *testing.T, st *store.Store, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := ulid.Make().String()
|
||||||
|
if err := st.CreateHost(context.Background(), store.Host{
|
||||||
|
ID: id, Name: name, OS: "linux", Arch: "amd64",
|
||||||
|
ProtocolVersion: api.CurrentProtocolVersion,
|
||||||
|
EnrolledAt: time.Now().UTC(),
|
||||||
|
}, "tokhash-"+id, ""); err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON issues a JSON request with the given method and body, returns
|
||||||
|
// status + decoded JSON map (nil on empty body).
|
||||||
|
func doJSON(t *testing.T, baseURL, method, path string, body any, cookie *stdhttp.Cookie) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
rdr = bytes.NewReader(raw)
|
||||||
|
}
|
||||||
|
req, err := stdhttp.NewRequest(method, baseURL+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new req: %v", err)
|
||||||
|
}
|
||||||
|
if rdr != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
res, err := stdhttp.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("do: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return res.StatusCode, nil
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
// Non-JSON (HTMX action paths return plain text on error).
|
||||||
|
return res.StatusCode, map[string]any{"raw": string(raw)}
|
||||||
|
}
|
||||||
|
return res.StatusCode, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- source-groups ------------------------------------------------
|
||||||
|
|
||||||
|
func TestSourceGroupsCRUD(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "sg-host")
|
||||||
|
|
||||||
|
// Empty list at start.
|
||||||
|
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/source-groups", nil, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("list status: %d", status)
|
||||||
|
}
|
||||||
|
if got := body["source_groups"].([]any); len(got) != 0 {
|
||||||
|
t.Fatalf("expected empty list, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
||||||
|
map[string]any{
|
||||||
|
"name": "etc",
|
||||||
|
"includes": []string{"/etc"},
|
||||||
|
"excludes": []string{"/etc/shadow"},
|
||||||
|
"retention_policy": map[string]int{
|
||||||
|
"keep_daily": 7,
|
||||||
|
},
|
||||||
|
"retry_max": 3,
|
||||||
|
"retry_backoff_seconds": 60,
|
||||||
|
}, cookie)
|
||||||
|
if status != 201 {
|
||||||
|
t.Fatalf("create status: %d, body: %+v", status, body)
|
||||||
|
}
|
||||||
|
gid, _ := body["id"].(string)
|
||||||
|
if gid == "" {
|
||||||
|
t.Fatalf("create: no id returned: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate name → 409.
|
||||||
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
|
||||||
|
map[string]any{"name": "etc", "includes": []string{"/x"}}, cookie)
|
||||||
|
if status != 409 {
|
||||||
|
t.Errorf("duplicate name: want 409, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update — rename + add another include.
|
||||||
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
|
||||||
|
map[string]any{
|
||||||
|
"name": "system",
|
||||||
|
"includes": []string{"/etc", "/var/log"},
|
||||||
|
"retention_policy": map[string]int{
|
||||||
|
"keep_daily": 14,
|
||||||
|
"keep_weekly": 4,
|
||||||
|
},
|
||||||
|
}, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("update status: %d, body: %+v", status, body)
|
||||||
|
}
|
||||||
|
if got := body["name"]; got != "system" {
|
||||||
|
t.Errorf("rename: got %v want system", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
||||||
|
if status != 204 {
|
||||||
|
t.Errorf("delete status: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already gone.
|
||||||
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
||||||
|
if status != 404 {
|
||||||
|
t.Errorf("delete-after-delete: want 404, got %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceGroupDeleteRefusesIfInUse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "sg-inuse-host")
|
||||||
|
|
||||||
|
// Create a group via the store directly.
|
||||||
|
gid := ulid.Make().String()
|
||||||
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||||
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/home"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create group: %v", err)
|
||||||
|
}
|
||||||
|
// Attach a schedule.
|
||||||
|
sid := ulid.Make().String()
|
||||||
|
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||||
|
ID: sid, HostID: hostID,
|
||||||
|
CronExpr: "0 3 * * *", Enabled: true,
|
||||||
|
SourceGroupIDs: []string{gid},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create schedule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, body := doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
|
||||||
|
if status != 409 {
|
||||||
|
t.Fatalf("want 409, got %d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
if body["code"] != "group_in_use" {
|
||||||
|
t.Errorf("wrong code: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- schedules ----------------------------------------------------
|
||||||
|
|
||||||
|
func TestSchedulesCRUDValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "sched-host")
|
||||||
|
|
||||||
|
// Bad cron → 400.
|
||||||
|
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
||||||
|
map[string]any{"cron": "not-a-cron", "enabled": true,
|
||||||
|
"source_group_ids": []string{"x"}}, cookie)
|
||||||
|
if status != 400 {
|
||||||
|
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing groups → 400.
|
||||||
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
||||||
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
||||||
|
"source_group_ids": []string{}}, cookie)
|
||||||
|
if status != 400 {
|
||||||
|
t.Errorf("missing groups: want 400, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group not on host → 400.
|
||||||
|
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
||||||
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
||||||
|
"source_group_ids": []string{"non-existent"}}, cookie)
|
||||||
|
if status != 400 {
|
||||||
|
t.Errorf("bogus group: want 400, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a real group.
|
||||||
|
gid := ulid.Make().String()
|
||||||
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||||
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Happy create.
|
||||||
|
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
|
||||||
|
map[string]any{"cron": "0 3 * * *", "enabled": true,
|
||||||
|
"source_group_ids": []string{gid}}, cookie)
|
||||||
|
if status != 201 {
|
||||||
|
t.Fatalf("create: %d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
sid, _ := body["id"].(string)
|
||||||
|
if sid == "" {
|
||||||
|
t.Fatalf("no id: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List.
|
||||||
|
status, body = doJSON(t, url, "GET", "/api/hosts/"+hostID+"/schedules", nil, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("list: %d", status)
|
||||||
|
}
|
||||||
|
rows, _ := body["schedules"].([]any)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("expected 1 schedule, got %d", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update — change cron, keep group.
|
||||||
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
|
||||||
|
map[string]any{"cron": "@hourly", "enabled": false,
|
||||||
|
"source_group_ids": []string{gid}}, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("update: %d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
if body["cron"] != "@hourly" || body["enabled"] != false {
|
||||||
|
t.Errorf("update fields: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/schedules/"+sid, nil, cookie)
|
||||||
|
if status != 204 {
|
||||||
|
t.Errorf("delete: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- repo-maintenance --------------------------------------------
|
||||||
|
|
||||||
|
func TestRepoMaintenanceGetSeedsAndPutValidates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "maint-host")
|
||||||
|
|
||||||
|
// GET on a host that hasn't had the row seeded auto-creates one.
|
||||||
|
status, body := doJSON(t, url, "GET", "/api/hosts/"+hostID+"/repo-maintenance", nil, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("get: %d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
if body["host_id"] != hostID {
|
||||||
|
t.Errorf("host_id mismatch: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT with bad cron.
|
||||||
|
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
||||||
|
map[string]any{
|
||||||
|
"forget_cron": "junk", "prune_cron": "@weekly",
|
||||||
|
"check_cron": "@monthly", "check_subset_pct": 10,
|
||||||
|
}, cookie)
|
||||||
|
if status != 400 {
|
||||||
|
t.Errorf("bad cron: want 400, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT with subset out of range.
|
||||||
|
status, _ = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
||||||
|
map[string]any{
|
||||||
|
"forget_cron": "@daily", "prune_cron": "@weekly",
|
||||||
|
"check_cron": "@monthly", "check_subset_pct": 200,
|
||||||
|
}, cookie)
|
||||||
|
if status != 400 {
|
||||||
|
t.Errorf("bad subset: want 400, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Happy PUT.
|
||||||
|
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/repo-maintenance",
|
||||||
|
map[string]any{
|
||||||
|
"forget_cron": "@daily",
|
||||||
|
"forget_enabled": true,
|
||||||
|
"prune_cron": "@weekly",
|
||||||
|
"prune_enabled": true,
|
||||||
|
"check_cron": "@monthly",
|
||||||
|
"check_enabled": false,
|
||||||
|
"check_subset_pct": 25,
|
||||||
|
}, cookie)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("happy put: %d body=%+v", status, body)
|
||||||
|
}
|
||||||
|
if body["forget_cron"] != "@daily" || body["check_subset_pct"] != float64(25) {
|
||||||
|
t.Errorf("fields: %+v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 410 Gone on retired routes ----------------------------------
|
||||||
|
|
||||||
|
func TestPerHostRunBackupReturns410(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "gone-host")
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST", url+"/hosts/"+hostID+"/run-backup", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
res, err := stdhttp.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusGone {
|
||||||
|
t.Errorf("want 410, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- schedule_push payload ---------------------------------------
|
||||||
|
|
||||||
|
func TestBuildScheduleSetPayload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srv, _, st := newTestServerWithHub(t)
|
||||||
|
hostID := makeHost(t, st, "push-host")
|
||||||
|
|
||||||
|
gid := ulid.Make().String()
|
||||||
|
keepDaily := 7
|
||||||
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||||
|
ID: gid, HostID: hostID, Name: "default",
|
||||||
|
Includes: []string{"/etc", "/home"},
|
||||||
|
Excludes: []string{"/etc/shadow"},
|
||||||
|
RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
|
||||||
|
RetryMax: 2, RetryBackoffSeconds: 30,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("group: %v", err)
|
||||||
|
}
|
||||||
|
sid := ulid.Make().String()
|
||||||
|
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||||
|
ID: sid, HostID: hostID,
|
||||||
|
CronExpr: "0 3 * * *", Enabled: true,
|
||||||
|
SourceGroupIDs: []string{gid},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("schedule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := srv.buildScheduleSetPayload(context.Background(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Version == 0 {
|
||||||
|
t.Fatalf("version should be > 0, got %d", payload.Version)
|
||||||
|
}
|
||||||
|
if len(payload.Schedules) != 1 {
|
||||||
|
t.Fatalf("schedules: %d", len(payload.Schedules))
|
||||||
|
}
|
||||||
|
entry := payload.Schedules[0]
|
||||||
|
if entry.ID != sid || entry.CronExpr != "0 3 * * *" || !entry.Enabled {
|
||||||
|
t.Errorf("schedule fields: %+v", entry)
|
||||||
|
}
|
||||||
|
if len(entry.SourceGroups) != 1 {
|
||||||
|
t.Fatalf("groups in schedule: %d", len(entry.SourceGroups))
|
||||||
|
}
|
||||||
|
g := entry.SourceGroups[0]
|
||||||
|
if g.Name != "default" {
|
||||||
|
t.Errorf("group name: %s", g.Name)
|
||||||
|
}
|
||||||
|
if !equalStrings(g.Includes, []string{"/etc", "/home"}) {
|
||||||
|
t.Errorf("includes: %v", g.Includes)
|
||||||
|
}
|
||||||
|
var rp map[string]any
|
||||||
|
_ = json.Unmarshal(g.RetentionPolicy, &rp)
|
||||||
|
if rp["keep_daily"] != float64(7) {
|
||||||
|
t.Errorf("retention: %+v", rp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- per-source-group Run-now -----------------------------------
|
||||||
|
|
||||||
|
func TestRunSourceGroupOfflineHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "offline-host")
|
||||||
|
|
||||||
|
gid := ulid.Make().String()
|
||||||
|
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||||
|
ID: gid, HostID: hostID, Name: "default", Includes: []string{"/etc"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON path → 503 (host offline).
|
||||||
|
req, _ := stdhttp.NewRequest("POST",
|
||||||
|
url+"/hosts/"+hostID+"/source-groups/"+gid+"/run", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, _ := stdhttp.DefaultClient.Do(req)
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusServiceUnavailable {
|
||||||
|
t.Errorf("offline: want 503, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSourceGroupUnknownGroup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, url, st := newTestServerWithHub(t)
|
||||||
|
cookie := loginAsAdmin(t, st)
|
||||||
|
hostID := makeHost(t, st, "noh-host")
|
||||||
|
|
||||||
|
req, _ := stdhttp.NewRequest("POST",
|
||||||
|
url+"/hosts/"+hostID+"/source-groups/no-such-gid/run", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
res, _ := stdhttp.DefaultClient.Do(req)
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != stdhttp.StatusNotFound {
|
||||||
|
t.Errorf("unknown group: want 404, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep fmt import live — used for occasional debug.
|
||||||
|
var _ = fmt.Sprintf
|
||||||
|
var _ = strings.HasPrefix
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// repo_maintenance.go — REST API for /api/hosts/{id}/repo-maintenance.
|
||||||
|
//
|
||||||
|
// Cadence rows for the three repo-wide verbs (forget / prune / check).
|
||||||
|
// Edits do NOT bump host_schedule_version: the server-side maintenance
|
||||||
|
// ticker drives execution (P2R-06), not the agent's local cron.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
stdhttp "net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repoMaintenanceView struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
ForgetCron string `json:"forget_cron"`
|
||||||
|
ForgetEnabled bool `json:"forget_enabled"`
|
||||||
|
PruneCron string `json:"prune_cron"`
|
||||||
|
PruneEnabled bool `json:"prune_enabled"`
|
||||||
|
CheckCron string `json:"check_cron"`
|
||||||
|
CheckEnabled bool `json:"check_enabled"`
|
||||||
|
CheckSubsetPct int `json:"check_subset_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRepoMaintenanceView(m store.HostRepoMaintenance) repoMaintenanceView {
|
||||||
|
return repoMaintenanceView{
|
||||||
|
HostID: m.HostID,
|
||||||
|
ForgetCron: m.ForgetCron,
|
||||||
|
ForgetEnabled: m.ForgetEnabled,
|
||||||
|
PruneCron: m.PruneCron,
|
||||||
|
PruneEnabled: m.PruneEnabled,
|
||||||
|
CheckCron: m.CheckCron,
|
||||||
|
CheckEnabled: m.CheckEnabled,
|
||||||
|
CheckSubsetPct: m.CheckSubsetPct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
// Self-heal: seed and return the defaults so the UI never
|
||||||
|
// has to handle a 404 here. Hosts enrolled before the
|
||||||
|
// migration may legitimately be missing the row.
|
||||||
|
if seedErr := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); seedErr != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err = s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*m))
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoMaintenanceWriteRequest struct {
|
||||||
|
ForgetCron string `json:"forget_cron"`
|
||||||
|
ForgetEnabled bool `json:"forget_enabled"`
|
||||||
|
PruneCron string `json:"prune_cron"`
|
||||||
|
PruneEnabled bool `json:"prune_enabled"`
|
||||||
|
CheckCron string `json:"check_cron"`
|
||||||
|
CheckEnabled bool `json:"check_enabled"`
|
||||||
|
CheckSubsetPct int `json:"check_subset_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req repoMaintenanceWriteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for label, expr := range map[string]string{
|
||||||
|
"forget_cron": req.ForgetCron,
|
||||||
|
"prune_cron": req.PruneCron,
|
||||||
|
"check_cron": req.CheckCron,
|
||||||
|
} {
|
||||||
|
if expr == "" {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", label+" required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := cronParser.Parse(expr); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_cron", label+": "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.CheckSubsetPct < 0 || req.CheckSubsetPct > 100 {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_value",
|
||||||
|
"check_subset_pct must be 0..100")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ensure the row exists (older hosts may pre-date the auto-seed).
|
||||||
|
if err := s.deps.Store.CreateDefaultRepoMaintenance(r.Context(), hostID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := store.HostRepoMaintenance{
|
||||||
|
HostID: hostID,
|
||||||
|
ForgetCron: req.ForgetCron,
|
||||||
|
ForgetEnabled: req.ForgetEnabled,
|
||||||
|
PruneCron: req.PruneCron,
|
||||||
|
PruneEnabled: req.PruneEnabled,
|
||||||
|
CheckCron: req.CheckCron,
|
||||||
|
CheckEnabled: req.CheckEnabled,
|
||||||
|
CheckSubsetPct: req.CheckSubsetPct,
|
||||||
|
}
|
||||||
|
if err := s.deps.Store.UpdateRepoMaintenance(r.Context(), &m); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, _ := s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
|
||||||
|
if out != nil {
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(*out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// run_group.go — per-source-group Run-now endpoint.
|
||||||
|
//
|
||||||
|
// POST /hosts/{id}/source-groups/{gid}/run dispatches a backup job
|
||||||
|
// against the resolved includes/excludes/retention/tag of the named
|
||||||
|
// group. Replaces the old per-host /hosts/{id}/run-backup route (now
|
||||||
|
// 410 Gone).
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
stdhttp "net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
user, ok := s.requireUser(r)
|
||||||
|
if !ok {
|
||||||
|
// HTML callers redirect to login; for JSON return 401.
|
||||||
|
if wantsHTML(r) {
|
||||||
|
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
groupID := chi.URLParam(r, "gid")
|
||||||
|
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
s.runGroupError(w, r, stdhttp.StatusNotFound, "group_not_found",
|
||||||
|
"source group not found on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.runGroupError(w, r, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retention, _ := json.Marshal(g.RetentionPolicy)
|
||||||
|
res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobBackup,
|
||||||
|
api.CommandRunPayload{
|
||||||
|
Includes: g.Includes,
|
||||||
|
Excludes: g.Excludes,
|
||||||
|
Tag: g.Name,
|
||||||
|
RetentionPolicy: retention,
|
||||||
|
})
|
||||||
|
if code != "" {
|
||||||
|
s.runGroupError(w, r, status, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wantsHTML(r) {
|
||||||
|
// HTMX action: redirect to the live job log so the operator
|
||||||
|
// sees streaming output immediately.
|
||||||
|
w.Header().Set("HX-Redirect", "/jobs/"+res.JobID)
|
||||||
|
w.WriteHeader(stdhttp.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusAccepted, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGroupError dispatches an error to JSON callers as the standard
|
||||||
|
// envelope; HTMX callers get a 4xx with a plain text body so the
|
||||||
|
// browser surfaces it via the existing toast handler.
|
||||||
|
func (s *Server) runGroupError(w stdhttp.ResponseWriter, r *stdhttp.Request, status int, code, msg string) {
|
||||||
|
if wantsHTML(r) {
|
||||||
|
stdhttp.Error(w, msg, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, code, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsHTML keys off HX-Request only. Browsers sending a default
|
||||||
|
// Accept (or curl's `*/*`) get the JSON shape, which is the safer
|
||||||
|
// default for non-htmx clients. HTMX always sets HX-Request=true on
|
||||||
|
// its action POSTs, so the form path is unambiguous.
|
||||||
|
func wantsHTML(r *stdhttp.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
@@ -1,37 +1,218 @@
|
|||||||
|
// schedule_push.go — server → agent reconciliation push and the
|
||||||
|
// inbound schedule.fire dispatch.
|
||||||
|
//
|
||||||
|
// The slim-schedule wire shape is built here from the (Schedule,
|
||||||
|
// SourceGroup) pair. Each schedule is sent with its resolved source
|
||||||
|
// groups inlined so the agent doesn't have to keep its own copy of
|
||||||
|
// the group catalogue. Cron + enabled drive the agent's local timer;
|
||||||
|
// when an entry fires the agent ships back a schedule.fire and
|
||||||
|
// dispatchScheduledJob below resolves the schedule's groups and
|
||||||
|
// dispatches one backup command.run per group.
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"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/server/ws"
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// schedule_push.go — server → agent reconciliation push.
|
// pushScheduleSetOnConn ships the canonical schedule set straight down
|
||||||
//
|
// the freshly-accepted hello connection. Callers are inside the
|
||||||
// Stubbed during the P2 redesign rewrite. The new wire shape (slim
|
// hello window — using the conn directly avoids racing the hub's
|
||||||
// schedules referencing source groups; groups inline by id at push
|
// register-then-supersede sequence.
|
||||||
// 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.
|
|
||||||
|
|
||||||
func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) {
|
func (s *Server) pushScheduleSetOnConn(ctx context.Context, hostID string, conn *ws.Conn) {
|
||||||
slog.Debug("schedule push: stubbed during P2 redesign", "host_id", hostID)
|
payload, err := s.buildScheduleSetPayload(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule push: build payload", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env, err := api.Marshal(api.MsgScheduleSet, "", payload)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule push: marshal payload", "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("schedule push on-hello: send", "host_id", hostID, "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushScheduleSetAsync pushes the latest schedule set to a connected
|
||||||
|
// agent (via the hub) on a best-effort basis. Mutations call this
|
||||||
|
// after a successful CRUD; offline agents pick the new version up on
|
||||||
|
// next reconnect via pushScheduleSetOnConn.
|
||||||
func (s *Server) pushScheduleSetAsync(hostID string) {
|
func (s *Server) pushScheduleSetAsync(hostID string) {
|
||||||
slog.Debug("schedule push async: stubbed during P2 redesign", "host_id", hostID)
|
if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
payload, err := s.buildScheduleSetPayload(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule push async: build payload", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env, err := api.Marshal(api.MsgScheduleSet, "", payload)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule push async: marshal", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.Hub.Send(ctx, hostID, env); err != nil {
|
||||||
|
slog.Debug("schedule push async: send", "host_id", hostID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildScheduleSetPayload assembles the canonical wire shape: every
|
||||||
|
// schedule for the host with its source groups resolved inline.
|
||||||
|
func (s *Server) buildScheduleSetPayload(ctx context.Context, hostID string) (api.ScheduleSetPayload, error) {
|
||||||
|
version, err := s.deps.Store.GetHostScheduleVersion(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return api.ScheduleSetPayload{}, err
|
||||||
|
}
|
||||||
|
schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return api.ScheduleSetPayload{}, err
|
||||||
|
}
|
||||||
|
groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return api.ScheduleSetPayload{}, err
|
||||||
|
}
|
||||||
|
groupByID := make(map[string]store.SourceGroup, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
groupByID[g.ID] = g
|
||||||
|
}
|
||||||
|
|
||||||
|
out := api.ScheduleSetPayload{Version: version, Schedules: make([]api.Schedule, 0, len(schedules))}
|
||||||
|
for _, sc := range schedules {
|
||||||
|
entry := api.Schedule{
|
||||||
|
ID: sc.ID,
|
||||||
|
CronExpr: sc.CronExpr,
|
||||||
|
Enabled: sc.Enabled,
|
||||||
|
SourceGroups: make([]api.ScheduleSourceGroup, 0, len(sc.SourceGroupIDs)),
|
||||||
|
}
|
||||||
|
for _, gid := range sc.SourceGroupIDs {
|
||||||
|
g, ok := groupByID[gid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
retention, _ := json.Marshal(g.RetentionPolicy)
|
||||||
|
entry.SourceGroups = append(entry.SourceGroups, api.ScheduleSourceGroup{
|
||||||
|
Name: g.Name,
|
||||||
|
Includes: g.Includes,
|
||||||
|
Excludes: g.Excludes,
|
||||||
|
RetentionPolicy: retention,
|
||||||
|
RetryMax: g.RetryMax,
|
||||||
|
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.Schedules = append(out.Schedules, entry)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyScheduleAck persists the version the agent has confirmed.
|
||||||
func (s *Server) applyScheduleAck(ctx context.Context, hostID string, version int64, appliedAt time.Time) {
|
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 {
|
if err := s.deps.Store.SetHostAppliedScheduleVersion(ctx, hostID, version); err != nil {
|
||||||
slog.Warn("schedule.ack: persist applied version", "host_id", hostID, "err", err)
|
slog.Warn("schedule.ack: persist applied version", "host_id", hostID, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchScheduledJob handles an agent's schedule.fire. Resolves the
|
||||||
|
// schedule's source groups and dispatches one backup command.run per
|
||||||
|
// group, persisting each as a job row with actor_kind=schedule and
|
||||||
|
// scheduled_id pointing at the schedule.
|
||||||
func (s *Server) dispatchScheduledJob(ctx context.Context, hostID string, conn *ws.Conn, scheduleID string, scheduledAt time.Time) {
|
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",
|
sc, err := s.deps.Store.GetSchedule(ctx, hostID, scheduleID)
|
||||||
"host_id", hostID, "schedule_id", scheduleID, "scheduled_at", scheduledAt)
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
slog.Info("schedule.fire: schedule unknown, ignoring",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Warn("schedule.fire: load schedule", "host_id", hostID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sc.Enabled {
|
||||||
|
slog.Info("schedule.fire: schedule disabled, ignoring",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(sc.SourceGroupIDs) == 0 {
|
||||||
|
slog.Warn("schedule.fire: schedule has no source groups",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, gid := range sc.SourceGroupIDs {
|
||||||
|
g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule.fire: load source group",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID, "group_id", gid, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.dispatchBackupForGroup(ctx, conn, hostID, scheduleID, g, scheduledAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchBackupForGroup builds and sends a single backup command.run
|
||||||
|
// envelope on conn for the given group. Persists the job row first so
|
||||||
|
// the live log viewer can subscribe to it.
|
||||||
|
func (s *Server) dispatchBackupForGroup(ctx context.Context, conn *ws.Conn, hostID, scheduleID string, g *store.SourceGroup, scheduledAt time.Time) {
|
||||||
|
jobID := ulid.Make().String()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
scheduleRef := scheduleID
|
||||||
|
if err := s.deps.Store.CreateJob(ctx, store.Job{
|
||||||
|
ID: jobID,
|
||||||
|
HostID: hostID,
|
||||||
|
Kind: string(api.JobBackup),
|
||||||
|
ScheduledID: &scheduleRef,
|
||||||
|
ActorKind: "schedule",
|
||||||
|
CreatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("schedule.fire: persist job", "host_id", hostID,
|
||||||
|
"schedule_id", scheduleID, "group", g.Name, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retention, _ := json.Marshal(g.RetentionPolicy)
|
||||||
|
env, err := api.Marshal(api.MsgCommandRun, jobID, api.CommandRunPayload{
|
||||||
|
JobID: jobID,
|
||||||
|
Kind: api.JobBackup,
|
||||||
|
Includes: g.Includes,
|
||||||
|
Excludes: g.Excludes,
|
||||||
|
Tag: g.Name,
|
||||||
|
RetentionPolicy: retention,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("schedule.fire: marshal command.run",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := conn.Send(sendCtx, env); err != nil {
|
||||||
|
slog.Warn("schedule.fire: send command.run",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
|
||||||
|
ID: ulid.Make().String(),
|
||||||
|
Actor: "schedule",
|
||||||
|
Action: "job.run_now",
|
||||||
|
TargetKind: ptr("job"),
|
||||||
|
TargetID: &jobID,
|
||||||
|
TS: now,
|
||||||
|
})
|
||||||
|
slog.Info("schedule.fire: dispatched backup",
|
||||||
|
"host_id", hostID, "schedule_id", scheduleID,
|
||||||
|
"group", g.Name, "job_id", jobID, "scheduled_at", scheduledAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,219 @@
|
|||||||
|
// 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
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
stdhttp "net/http"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// schedules.go — REST API for /api/hosts/{id}/schedules.
|
// scheduleView is the JSON shape returned by GET. Stable wire format
|
||||||
//
|
// — UI form binds to it.
|
||||||
// Stubbed during the P2 redesign data-model rewrite (commit chain).
|
type scheduleView struct {
|
||||||
// Phase 2 dropped the fat Schedule shape (paths/excludes/tags/
|
ID string `json:"id"`
|
||||||
// retention/manual/kind/options/hooks) — the slim Schedule + source
|
HostID string `json:"host_id"`
|
||||||
// groups model lives in store/. Phase 3 of the redesign will fill in
|
CronExpr string `json:"cron"`
|
||||||
// these handlers against the new shape.
|
Enabled bool `json:"enabled"`
|
||||||
//
|
SourceGroupIDs []string `json:"source_group_ids"`
|
||||||
// Returning 501 here keeps the routes addressable; UI calls will
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// surface the unimplemented state via the toast component until the
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
// new handlers land.
|
}
|
||||||
|
|
||||||
|
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) {
|
func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
if !s.authedUser(r) {
|
||||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
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) {
|
func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
if !s.authedUser(r) {
|
||||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
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) {
|
func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
if !s.authedUser(r) {
|
||||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
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.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) {
|
func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
writeJSONError(w, stdhttp.StatusNotImplemented, "redesign_in_progress",
|
if !s.authedUser(r) {
|
||||||
"schedule REST API is being rebuilt — see P2 redesign Phase 3")
|
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.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,15 +105,43 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
|
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
|
||||||
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
|
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
|
||||||
|
|
||||||
// Per-host schedule CRUD. Mutations bump host_schedule_version;
|
// Per-host schedule CRUD. Mutations bump host_schedule_version
|
||||||
// the agent sync path (P2-02) picks up the new version on the
|
// and async-push to a connected agent (see schedule_push.go).
|
||||||
// next reconciliation tick.
|
|
||||||
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
|
r.Get("/hosts/{id}/schedules", s.handleListSchedules)
|
||||||
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
|
r.Post("/hosts/{id}/schedules", s.handleCreateSchedule)
|
||||||
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
|
r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
|
||||||
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
|
r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
|
||||||
|
|
||||||
|
// Source-group CRUD. A group is "what gets backed up" — paths,
|
||||||
|
// excludes, retention, retry. Group name doubles as the
|
||||||
|
// snapshot tag (restic --tag <name>).
|
||||||
|
r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups)
|
||||||
|
r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup)
|
||||||
|
r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
|
||||||
|
r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
|
||||||
|
r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
|
||||||
|
|
||||||
|
// Repo maintenance cadences (forget / prune / check). Driven
|
||||||
|
// by the server-side ticker (P2R-06), not the agent's cron.
|
||||||
|
r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
|
||||||
|
r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
|
||||||
|
|
||||||
|
// Per-source-group Run-now (JSON variant). HTMX action is
|
||||||
|
// mounted at the equivalent path outside /api below — both
|
||||||
|
// resolve to the same handler, which sniffs HX-Request.
|
||||||
|
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Per-source-group Run-now (HTMX form action). Available even
|
||||||
|
// when the server is started without UI templates so REST callers
|
||||||
|
// against the non-/api path also work.
|
||||||
|
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
|
||||||
|
// Retired routes — see ui_handlers.go for the messages. Mounted
|
||||||
|
// outside the UI gate so cached browser tabs get a clear 410
|
||||||
|
// even if the server runs without templates.
|
||||||
|
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
|
||||||
|
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
|
||||||
|
|
||||||
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
|
// Agent ↔ server WebSocket. Bearer-authenticated inside the handler.
|
||||||
if s.deps.Hub != nil {
|
if s.deps.Hub != nil {
|
||||||
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
||||||
@@ -143,11 +171,9 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Get("/login", s.handleUILoginGet)
|
r.Get("/login", s.handleUILoginGet)
|
||||||
r.Post("/login", s.handleUILoginPost)
|
r.Post("/login", s.handleUILoginPost)
|
||||||
r.Post("/logout", s.handleUILogoutPost)
|
r.Post("/logout", s.handleUILogoutPost)
|
||||||
// HTMX action endpoint for "Run now" buttons on the dashboard.
|
// Per-host Run-now and manual Init-repo are mounted at the
|
||||||
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup)
|
// outer router (so they reply 410 even without UI). Per-
|
||||||
// HTMX action endpoint for the red "Initialise repo" button
|
// source-group Run-now lives there too — same reason.
|
||||||
// shown in the run-now panel until the repo is confirmed init'd.
|
|
||||||
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepo)
|
|
||||||
// Add host flow.
|
// Add host flow.
|
||||||
r.Get("/hosts/new", s.handleUIAddHostGet)
|
r.Get("/hosts/new", s.handleUIAddHostGet)
|
||||||
r.Post("/hosts/new", s.handleUIAddHostPost)
|
r.Post("/hosts/new", s.handleUIAddHostPost)
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// source_groups.go — REST API for /api/hosts/{id}/source-groups.
|
||||||
|
//
|
||||||
|
// A source group is "what gets backed up": a named bundle of include
|
||||||
|
// + exclude paths, a retention policy, and retry knobs. Group name
|
||||||
|
// doubles as the snapshot tag (restic --tag <name>).
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceGroupView is the JSON shape returned by GET endpoints.
|
||||||
|
type sourceGroupView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Includes []string `json:"includes"`
|
||||||
|
Excludes []string `json:"excludes"`
|
||||||
|
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
|
||||||
|
RetryMax int `json:"retry_max"`
|
||||||
|
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
||||||
|
ConflictDimension string `json:"conflict_dimension,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSourceGroupView(g store.SourceGroup) sourceGroupView {
|
||||||
|
includes := g.Includes
|
||||||
|
if includes == nil {
|
||||||
|
includes = []string{}
|
||||||
|
}
|
||||||
|
excludes := g.Excludes
|
||||||
|
if excludes == nil {
|
||||||
|
excludes = []string{}
|
||||||
|
}
|
||||||
|
return sourceGroupView{
|
||||||
|
ID: g.ID, HostID: g.HostID, Name: g.Name,
|
||||||
|
Includes: includes, Excludes: excludes,
|
||||||
|
RetentionPolicy: g.RetentionPolicy,
|
||||||
|
RetryMax: g.RetryMax,
|
||||||
|
RetryBackoffSeconds: g.RetryBackoffSeconds,
|
||||||
|
ConflictDimension: g.ConflictDimension,
|
||||||
|
CreatedAt: g.CreatedAt,
|
||||||
|
UpdatedAt: g.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceGroupWriteRequest is the body of POST and PUT.
|
||||||
|
type sourceGroupWriteRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Includes []string `json:"includes"`
|
||||||
|
Excludes []string `json:"excludes"`
|
||||||
|
RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
|
||||||
|
RetryMax int `json:"retry_max"`
|
||||||
|
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]sourceGroupView, 0, len(rows))
|
||||||
|
for _, g := range rows {
|
||||||
|
out = append(out, toSourceGroupView(g))
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, struct {
|
||||||
|
SourceGroups []sourceGroupView `json:"source_groups"`
|
||||||
|
}{SourceGroups: out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
groupID := chi.URLParam(r, "gid")
|
||||||
|
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*g))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req sourceGroupWriteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Name must be unique per host (the store has a UNIQUE constraint
|
||||||
|
// but pre-check gives a friendlier error than a 500).
|
||||||
|
if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusConflict, "name_taken",
|
||||||
|
"a source group named "+req.Name+" already exists on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g := store.SourceGroup{
|
||||||
|
ID: ulid.Make().String(), HostID: hostID, Name: req.Name,
|
||||||
|
Includes: req.Includes, Excludes: req.Excludes,
|
||||||
|
RetentionPolicy: req.RetentionPolicy,
|
||||||
|
RetryMax: req.RetryMax,
|
||||||
|
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
||||||
|
}
|
||||||
|
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushScheduleSetAsync(hostID)
|
||||||
|
writeJSON(w, stdhttp.StatusCreated, toSourceGroupView(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
groupID := chi.URLParam(r, "gid")
|
||||||
|
if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req sourceGroupWriteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If renaming, ensure the new name doesn't collide with another group.
|
||||||
|
if existing, err := s.deps.Store.GetSourceGroupByName(r.Context(), hostID, req.Name); err == nil && existing != nil && existing.ID != groupID {
|
||||||
|
writeJSONError(w, stdhttp.StatusConflict, "name_taken",
|
||||||
|
"a source group named "+req.Name+" already exists on this host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g := store.SourceGroup{
|
||||||
|
ID: groupID, HostID: hostID, Name: req.Name,
|
||||||
|
Includes: req.Includes, Excludes: req.Excludes,
|
||||||
|
RetentionPolicy: req.RetentionPolicy,
|
||||||
|
RetryMax: req.RetryMax,
|
||||||
|
RetryBackoffSeconds: req.RetryBackoffSeconds,
|
||||||
|
}
|
||||||
|
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushScheduleSetAsync(hostID)
|
||||||
|
out, _ := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
|
||||||
|
if out != nil {
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(*out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stdhttp.StatusOK, toSourceGroupView(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteSourceGroup refuses to delete a group that is still
|
||||||
|
// referenced by any schedule. Returns 409 with the schedule list so
|
||||||
|
// the UI can offer "remove from these schedules first."
|
||||||
|
func (s *Server) handleDeleteSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
if !s.authedUser(r) {
|
||||||
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostID := chi.URLParam(r, "id")
|
||||||
|
groupID := chi.URLParam(r, "gid")
|
||||||
|
using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), groupID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(using) > 0 {
|
||||||
|
writeJSON(w, stdhttp.StatusConflict, struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Schedules []string `json:"schedules"`
|
||||||
|
}{
|
||||||
|
Code: "group_in_use",
|
||||||
|
Message: "remove this group from the listed schedules before deleting",
|
||||||
|
Schedules: using,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.Store.DeleteSourceGroup(r.Context(), hostID, groupID); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pushScheduleSetAsync(hostID)
|
||||||
|
w.WriteHeader(stdhttp.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -142,23 +142,21 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUIRunBackup and handleUIInitRepo are stubbed during the P2
|
// Per-host Run-now and manual Init-repo were retired by the P2 redesign.
|
||||||
// redesign data-model rewrite. The dashboard per-host Run-now button
|
// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs
|
||||||
// is going away (operator clicks into host detail then a per-source-
|
// automatically on the agent's first WS connect after enrolment. Both
|
||||||
// group Run-now), and Init-repo becomes implicit at host enrolment
|
// routes return 410 Gone so any cached browser tab gets a clear error.
|
||||||
// (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) {
|
func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
stdhttp.Error(w,
|
stdhttp.Error(w,
|
||||||
"per-host Run-now is being replaced by per-source-group Run-now — see P2 redesign Phase 4",
|
"per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run",
|
||||||
stdhttp.StatusNotImplemented)
|
stdhttp.StatusGone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
stdhttp.Error(w,
|
stdhttp.Error(w,
|
||||||
"manual Init-repo is being replaced by auto-init at host enrolment — see P2 redesign Phase 6",
|
"manual init-repo is gone — the server auto-inits on the agent's first connect",
|
||||||
stdhttp.StatusNotImplemented)
|
stdhttp.StatusGone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addHostPage carries the Add-host form state. The result-state
|
// addHostPage carries the Add-host form state. The result-state
|
||||||
|
|||||||
@@ -193,6 +193,24 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) {
|
|||||||
return &j, nil
|
return &j, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasJobOfKind reports whether any job of the given kind exists for
|
||||||
|
// this host, regardless of status. Used by the auto-init path on
|
||||||
|
// agent hello to decide whether to dispatch a fresh `restic init` —
|
||||||
|
// once we've tried once we don't auto-retry, even on failure
|
||||||
|
// (failed init usually means bad creds; retrying every reconnect
|
||||||
|
// just piles up failed rows). The operator can re-init manually via
|
||||||
|
// the Repo page's danger zone.
|
||||||
|
func (s *Store) HasJobOfKind(ctx context.Context, hostID, kind string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = ?`,
|
||||||
|
hostID, kind).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("store: count jobs of kind: %w", err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func nullableStr(s string) any {
|
func nullableStr(s string) any {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -131,9 +131,9 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
|||||||
- [x] **P2R-00.3** (L) Go-side store rewrite against migration 0008. New types: `SourceGroup`, `HostRepoMaintenance`, `PendingRun`. `Schedule` slimmed to `{id, host_id, cron, enabled, source_group_ids, timestamps}`. `RetentionPolicy` moves from schedule field → source group field (type unchanged). `Host` loses `RepoInitialisedAt`, gains bandwidth caps. New files: `store/sources.go`, `store/maintenance.go`, `store/pending.go`. `store/schedules.go` rewritten for slim shape + junction CRUD. `enrollment.go` seeds a default source group + repo-maintenance row instead of a manual schedule. `ws/handler.go` drops `MarkHostRepoInitialised`. HTTP layer + UI templates **temporarily 501-stubbed** with `redesign_in_progress` — this is what P2R-01 / P2R-02 fill back in. Tests for the obsolete fat-schedule API deleted. Commit `5667cdf`.
|
- [x] **P2R-00.3** (L) Go-side store rewrite against migration 0008. New types: `SourceGroup`, `HostRepoMaintenance`, `PendingRun`. `Schedule` slimmed to `{id, host_id, cron, enabled, source_group_ids, timestamps}`. `RetentionPolicy` moves from schedule field → source group field (type unchanged). `Host` loses `RepoInitialisedAt`, gains bandwidth caps. New files: `store/sources.go`, `store/maintenance.go`, `store/pending.go`. `store/schedules.go` rewritten for slim shape + junction CRUD. `enrollment.go` seeds a default source group + repo-maintenance row instead of a manual schedule. `ws/handler.go` drops `MarkHostRepoInitialised`. HTTP layer + UI templates **temporarily 501-stubbed** with `redesign_in_progress` — this is what P2R-01 / P2R-02 fill back in. Tests for the obsolete fat-schedule API deleted. Commit `5667cdf`.
|
||||||
- [x] **P2R-00.4** (S) Host-detail UI patched up enough to render: `RepoInitialisedAt` template refs removed, manual init-repo branches stripped, dead Schedules sub-tab demoted to inert div (matches Jobs/Repo/Settings), broken Run-now buttons disabled with P2-Phase-4 hints. Stop-gap until P2R-02 lands the real surface.
|
- [x] **P2R-00.4** (S) Host-detail UI patched up enough to render: `RepoInitialisedAt` template refs removed, manual init-repo branches stripped, dead Schedules sub-tab demoted to inert div (matches Jobs/Repo/Settings), broken Run-now buttons disabled with P2-Phase-4 hints. Stop-gap until P2R-02 lands the real surface.
|
||||||
|
|
||||||
### P2 redesign — Phase 3 (REST + WS rewire) — TODO
|
### P2 redesign — Phase 3 (REST + WS rewire) ✅
|
||||||
|
|
||||||
- [ ] **P2R-01** (L) HTTP/WS layer against the slim shape:
|
- [x] **P2R-01** (L) HTTP/WS layer against the slim shape:
|
||||||
- **Schedules REST CRUD**: `GET|POST /api/hosts/{id}/schedules`, `PUT|DELETE /api/hosts/{id}/schedules/{sid}`. Body shape is `{cron, enabled, source_group_ids[]}` — paths/excludes/retention/kind/manual all go away. Junction wiped + re-inserted on every update (per `store.UpdateSchedule`). Validation: cron parses via `robfig/cron/v3`; ≥1 `source_group_ids`; all referenced groups belong to the host.
|
- **Schedules REST CRUD**: `GET|POST /api/hosts/{id}/schedules`, `PUT|DELETE /api/hosts/{id}/schedules/{sid}`. Body shape is `{cron, enabled, source_group_ids[]}` — paths/excludes/retention/kind/manual all go away. Junction wiped + re-inserted on every update (per `store.UpdateSchedule`). Validation: cron parses via `robfig/cron/v3`; ≥1 `source_group_ids`; all referenced groups belong to the host.
|
||||||
- **Source-groups REST CRUD**: `GET|POST /api/hosts/{id}/source-groups`, `GET|PUT|DELETE /api/hosts/{id}/source-groups/{gid}`. Body: `{name, includes[], excludes[], retention_policy, retry_max, retry_backoff_seconds}`. Name uniqueness per host. Refuse delete if `SchedulesUsingGroup(gid)` is non-empty (return the schedule list so UI can show "remove from these schedules first"). Mutations bump `host_schedule_version`.
|
- **Source-groups REST CRUD**: `GET|POST /api/hosts/{id}/source-groups`, `GET|PUT|DELETE /api/hosts/{id}/source-groups/{gid}`. Body: `{name, includes[], excludes[], retention_policy, retry_max, retry_backoff_seconds}`. Name uniqueness per host. Refuse delete if `SchedulesUsingGroup(gid)` is non-empty (return the schedule list so UI can show "remove from these schedules first"). Mutations bump `host_schedule_version`.
|
||||||
- **Repo-maintenance REST**: `GET|PUT /api/hosts/{id}/repo-maintenance`. Body: `{forget_cadence, prune_cadence, check_cadence, check_subset_pct, enabled}`. Server-side ticker drives execution (P2R-04), so updates here do **not** bump `host_schedule_version`.
|
- **Repo-maintenance REST**: `GET|PUT /api/hosts/{id}/repo-maintenance`. Body: `{forget_cadence, prune_cadence, check_cadence, check_subset_pct, enabled}`. Server-side ticker drives execution (P2R-04), so updates here do **not** bump `host_schedule_version`.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user