148e61b33b
Two independent path lists for "what does this host back up?" was
a real divergence footgun — operator types one set at Add-host time
and a different set into a schedule, both end up in the same repo,
the snapshot history looks fine until restore. Resolution: drop
host.default_paths entirely; add a `manual` flag on schedules.
A manual schedule has paths/excludes/tags/retention like any other
but no cron — it fires only via per-schedule Run-now. Single source
of truth for what gets backed up.
Schema (migration 0007):
* schedules.manual INTEGER NOT NULL DEFAULT 0.
* For every host with non-empty default_paths, seed a manual
schedule with those paths and bump host_schedule_version.
* ALTER TABLE hosts DROP COLUMN default_paths.
* ALTER TABLE enrollment_tokens RENAME COLUMN default_paths
TO initial_paths.
Original draft of this migration rebuilt hosts via the
create-new + drop-old + rename-new pattern. With foreign_keys=ON
(set in the connection DSN), DROP TABLE on the parent fired
ON DELETE CASCADE on every child of hosts(id) — schedules /
jobs / snapshots / host_credentials all wiped on the smoke env
when I tried it. SQLite 3.35+ supports column-level ALTERs
directly, so we skip the rebuild dance and avoid the cascade
trap. Six lines of SQL instead of sixty, no FK risk.
Run-now rewiring:
* New `dispatchScheduleNow(hostID, scheduleID, conn?)` helper
unifies the agent-driven path (cron fire → schedule.fire →
OnScheduleFire callback) and the UI-driven path (operator
clicks Run-now on a schedule row). Conn arg is optional; nil
falls back to Hub.Send.
* New POST /hosts/{id}/schedules/{sid}/run endpoint — per-row
Run-now button on the schedules list.
* Dashboard's per-host Run-now (handleUIRunBackup) now picks the
host's only enabled manual schedule, falls back to the only
enabled schedule, else returns "pick one in Schedules tab".
Keeps one-click for the common case.
Agent:
* Scheduler skips manual schedules in cron build (silent — they're
a normal data shape, not an error).
* Wire Schedule struct gains Manual flag.
* Schedule.fire flow unchanged — the agent only ever fires
non-manual schedules anyway.
UI:
* Add-host form retitled "Initial schedule · manual" so the
operator knows the paths become an editable schedule under
the Schedules tab. Result page calls out the manual schedule
+ points at Host > Schedules.
* Schedule edit form: "Manual schedule" checkbox at the top of
the When section; toggling it hides/shows the cron field via
inline JS. Server-side validator skips the cron requirement
when manual=true.
* Schedule list shows a "manual" tag under the status pill and
renders the When column as "— run-now only —" for manual rows.
Each row gets a Run-now button when the schedule is enabled
and the host is online.
Tests + go test ./... green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
514 lines
15 KiB
Go
514 lines
15 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
stdhttp "net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/oklog/ulid/v2"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// schedulesListPage carries everything the Schedules tab needs.
|
|
type schedulesListPage struct {
|
|
Host store.Host
|
|
Schedules []store.Schedule
|
|
Version int64
|
|
AppliedVersion int64
|
|
}
|
|
|
|
// scheduleEditPage drives both the Create form (Schedule.ID empty)
|
|
// and the Edit form (Schedule populated). Errors come back via Error
|
|
// to be rendered as a banner; the rest of the fields hold the just-
|
|
// submitted raw values so a failed POST can re-render with the
|
|
// operator's typed input still in place.
|
|
type scheduleEditPage struct {
|
|
Host store.Host
|
|
IsNew bool
|
|
ScheduleID string
|
|
Error string
|
|
// Form values — strings so partial input survives validation
|
|
// errors (e.g. operator typed "abc" into keep_last).
|
|
CronExpr string
|
|
PathsRaw string
|
|
ExcludesRaw string
|
|
TagsRaw string
|
|
KeepLast string
|
|
KeepHourly string
|
|
KeepDaily string
|
|
KeepWeekly string
|
|
KeepMonthly string
|
|
KeepYearly string
|
|
LimitUpKBps string
|
|
LimitDownKBps string
|
|
Enabled bool
|
|
Manual bool
|
|
}
|
|
|
|
// handleUISchedulesList renders the Schedules sub-tab on a host.
|
|
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
|
|
if err != nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
version, _ := s.deps.Store.GetHostScheduleVersion(r.Context(), hostID)
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
view.Title = host.Name + " · schedules · restic-manager"
|
|
view.Page = schedulesListPage{
|
|
Host: *host,
|
|
Schedules: rows,
|
|
Version: version,
|
|
AppliedVersion: host.AppliedScheduleVersion,
|
|
}
|
|
if err := s.deps.UI.Render(w, "schedules_list", view); err != nil {
|
|
slog.Error("ui: render schedules_list", "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleUIScheduleNewGet renders the empty Create form.
|
|
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := s.baseView(u, "dashboard")
|
|
view.Title = "New schedule · " + host.Name
|
|
view.Page = scheduleEditPage{
|
|
Host: *host,
|
|
IsNew: true,
|
|
CronExpr: "0 3 * * *",
|
|
Enabled: true,
|
|
}
|
|
s.renderScheduleEdit(w, view)
|
|
}
|
|
|
|
// handleUIScheduleEditGet renders the Edit form pre-filled from the
|
|
// existing schedule row.
|
|
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
sched, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
page := scheduleEditPage{
|
|
Host: *host,
|
|
IsNew: false,
|
|
ScheduleID: sched.ID,
|
|
CronExpr: sched.CronExpr,
|
|
PathsRaw: strings.Join(sched.Paths, "\n"),
|
|
ExcludesRaw: strings.Join(sched.Excludes, "\n"),
|
|
TagsRaw: strings.Join(sched.Tags, ", "),
|
|
Enabled: sched.Enabled,
|
|
Manual: sched.Manual,
|
|
}
|
|
page.KeepLast = intStringPtr(sched.RetentionPolicy.KeepLast)
|
|
page.KeepHourly = intStringPtr(sched.RetentionPolicy.KeepHourly)
|
|
page.KeepDaily = intStringPtr(sched.RetentionPolicy.KeepDaily)
|
|
page.KeepWeekly = intStringPtr(sched.RetentionPolicy.KeepWeekly)
|
|
page.KeepMonthly = intStringPtr(sched.RetentionPolicy.KeepMonthly)
|
|
page.KeepYearly = intStringPtr(sched.RetentionPolicy.KeepYearly)
|
|
page.LimitUpKBps = intStringPtr(sched.Options.LimitUploadKBps)
|
|
page.LimitDownKBps = intStringPtr(sched.Options.LimitDownloadKBps)
|
|
|
|
view := s.baseView(u, "dashboard")
|
|
view.Title = "Edit schedule · " + host.Name
|
|
view.Page = page
|
|
s.renderScheduleEdit(w, view)
|
|
}
|
|
|
|
// handleUIScheduleSave handles POST for both create and update. The
|
|
// edit form posts to /hosts/{id}/schedules/new (for create) or
|
|
// /hosts/{id}/schedules/{sid}/edit (for update); we branch on whether
|
|
// {sid} is present in the route params.
|
|
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
storeUser, _, err := s.userByID(r, u.ID)
|
|
if err != nil || storeUser == nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
page := scheduleEditPage{
|
|
Host: *host,
|
|
IsNew: scheduleID == "",
|
|
ScheduleID: scheduleID,
|
|
CronExpr: strings.TrimSpace(r.PostForm.Get("cron_expr")),
|
|
PathsRaw: r.PostForm.Get("paths"),
|
|
ExcludesRaw: r.PostForm.Get("excludes"),
|
|
TagsRaw: strings.TrimSpace(r.PostForm.Get("tags")),
|
|
KeepLast: strings.TrimSpace(r.PostForm.Get("keep_last")),
|
|
KeepHourly: strings.TrimSpace(r.PostForm.Get("keep_hourly")),
|
|
KeepDaily: strings.TrimSpace(r.PostForm.Get("keep_daily")),
|
|
KeepWeekly: strings.TrimSpace(r.PostForm.Get("keep_weekly")),
|
|
KeepMonthly: strings.TrimSpace(r.PostForm.Get("keep_monthly")),
|
|
KeepYearly: strings.TrimSpace(r.PostForm.Get("keep_yearly")),
|
|
LimitUpKBps: strings.TrimSpace(r.PostForm.Get("limit_up_kbps")),
|
|
LimitDownKBps: strings.TrimSpace(r.PostForm.Get("limit_down_kbps")),
|
|
Enabled: r.PostForm.Get("enabled") == "on",
|
|
Manual: r.PostForm.Get("manual") == "on",
|
|
}
|
|
|
|
// Convert the raw form values into store-shape data, surfacing
|
|
// the first parse error as a banner.
|
|
paths := splitPaths(page.PathsRaw)
|
|
excludes := splitPaths(page.ExcludesRaw)
|
|
tags := splitCSV(page.TagsRaw)
|
|
|
|
retention, err := parseRetention(page)
|
|
if err != nil {
|
|
page.Error = err.Error()
|
|
s.renderEditPage(w, u, page)
|
|
return
|
|
}
|
|
options, err := parseOptions(page)
|
|
if err != nil {
|
|
page.Error = err.Error()
|
|
s.renderEditPage(w, u, page)
|
|
return
|
|
}
|
|
|
|
// Validate against the same rules the JSON API uses. Manual
|
|
// schedules skip the cron-expr requirement; everything else
|
|
// applies the same.
|
|
apiShape := scheduleAPI{
|
|
Kind: api.JobBackup,
|
|
CronExpr: page.CronExpr,
|
|
Paths: paths,
|
|
Manual: page.Manual,
|
|
}
|
|
if code, msg := validateSchedule(&apiShape); code != "" {
|
|
page.Error = uiErrorMessage(code, msg)
|
|
s.renderEditPage(w, u, page)
|
|
return
|
|
}
|
|
|
|
if page.IsNew {
|
|
row := store.Schedule{
|
|
ID: ulid.Make().String(),
|
|
HostID: hostID,
|
|
Kind: string(api.JobBackup),
|
|
CronExpr: page.CronExpr,
|
|
Paths: paths,
|
|
Excludes: excludes,
|
|
Tags: tags,
|
|
RetentionPolicy: retention,
|
|
Options: options,
|
|
Enabled: page.Enabled,
|
|
Manual: page.Manual,
|
|
}
|
|
if err := s.deps.Store.CreateSchedule(r.Context(), &row); err != nil {
|
|
page.Error = "Couldn't save schedule — see server log."
|
|
slog.Error("ui schedule create", "err", err)
|
|
s.renderEditPage(w, u, page)
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &storeUser.ID,
|
|
Actor: "user",
|
|
Action: "schedule.created",
|
|
TargetKind: ptr("schedule"),
|
|
TargetID: &row.ID,
|
|
TS: nowUTC(),
|
|
})
|
|
s.pushScheduleSetAsync(hostID)
|
|
} else {
|
|
existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
existing.CronExpr = page.CronExpr
|
|
existing.Paths = paths
|
|
existing.Excludes = excludes
|
|
existing.Tags = tags
|
|
existing.RetentionPolicy = retention
|
|
existing.Options = options
|
|
existing.Enabled = page.Enabled
|
|
existing.Manual = page.Manual
|
|
if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil {
|
|
page.Error = "Couldn't save schedule — see server log."
|
|
slog.Error("ui schedule update", "err", err)
|
|
s.renderEditPage(w, u, page)
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &storeUser.ID,
|
|
Actor: "user",
|
|
Action: "schedule.updated",
|
|
TargetKind: ptr("schedule"),
|
|
TargetID: &scheduleID,
|
|
TS: nowUTC(),
|
|
})
|
|
s.pushScheduleSetAsync(hostID)
|
|
}
|
|
|
|
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
// handleUIScheduleRun is the POST target of per-schedule Run-now
|
|
// buttons. Reuses dispatchScheduledJob (the same code path used by
|
|
// the agent's local cron firing) so manual + automated runs flow
|
|
// through identical job lifecycle. Sets HX-Redirect to the live
|
|
// log on success.
|
|
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !s.deps.Hub.Connected(hostID) {
|
|
stdhttp.Error(w, "agent is offline", stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = host
|
|
jobID, err := s.dispatchScheduleNow(r.Context(), hostID, scheduleID, nil)
|
|
if err != nil {
|
|
stdhttp.Error(w, err.Error(), stdhttp.StatusBadRequest)
|
|
return
|
|
}
|
|
target := "/jobs/" + jobID
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", target)
|
|
w.WriteHeader(stdhttp.StatusOK)
|
|
return
|
|
}
|
|
stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
// handleUIScheduleDelete is the POST target of the Delete buttons on
|
|
// the list view. Confirm-then-redirect; no AJAX.
|
|
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|
u := s.requireUIUser(w, r)
|
|
if u == nil {
|
|
return
|
|
}
|
|
hostID := chi.URLParam(r, "id")
|
|
scheduleID := chi.URLParam(r, "sid")
|
|
storeUser, _, err := s.userByID(r, u.ID)
|
|
if err != nil || storeUser == nil {
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
stdhttp.NotFound(w, r)
|
|
return
|
|
}
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
|
ID: ulid.Make().String(),
|
|
UserID: &storeUser.ID,
|
|
Actor: "user",
|
|
Action: "schedule.deleted",
|
|
TargetKind: ptr("schedule"),
|
|
TargetID: &scheduleID,
|
|
TS: nowUTC(),
|
|
})
|
|
s.pushScheduleSetAsync(hostID)
|
|
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) renderScheduleEdit(w stdhttp.ResponseWriter, view ui.ViewData) {
|
|
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
|
|
slog.Error("ui: render schedule_edit", "err", err)
|
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderEditPage(w stdhttp.ResponseWriter, u *ui.User, page scheduleEditPage) {
|
|
view := s.baseView(u, "dashboard")
|
|
if page.IsNew {
|
|
view.Title = "New schedule · " + page.Host.Name
|
|
} else {
|
|
view.Title = "Edit schedule · " + page.Host.Name
|
|
}
|
|
view.Page = page
|
|
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
|
s.renderScheduleEdit(w, view)
|
|
}
|
|
|
|
// ----- helpers --------------------------------------------------------
|
|
|
|
// splitCSV parses comma-separated values into a clean []string —
|
|
// leading/trailing whitespace trimmed, blanks dropped.
|
|
func splitCSV(s string) []string {
|
|
out := []string{}
|
|
for _, p := range strings.Split(s, ",") {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseRetention(p scheduleEditPage) (store.RetentionPolicy, error) {
|
|
var r store.RetentionPolicy
|
|
for _, f := range []struct {
|
|
raw string
|
|
dest **int
|
|
name string
|
|
}{
|
|
{p.KeepLast, &r.KeepLast, "keep last"},
|
|
{p.KeepHourly, &r.KeepHourly, "keep hourly"},
|
|
{p.KeepDaily, &r.KeepDaily, "keep daily"},
|
|
{p.KeepWeekly, &r.KeepWeekly, "keep weekly"},
|
|
{p.KeepMonthly, &r.KeepMonthly, "keep monthly"},
|
|
{p.KeepYearly, &r.KeepYearly, "keep yearly"},
|
|
} {
|
|
v, err := parsePosInt(f.raw)
|
|
if err != nil {
|
|
return r, errFmtf("%s: %s", f.name, err)
|
|
}
|
|
*f.dest = v
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func parseOptions(p scheduleEditPage) (store.ScheduleOptions, error) {
|
|
var o store.ScheduleOptions
|
|
up, err := parsePosInt(p.LimitUpKBps)
|
|
if err != nil {
|
|
return o, errFmtf("limit upload: %s", err)
|
|
}
|
|
o.LimitUploadKBps = up
|
|
down, err := parsePosInt(p.LimitDownKBps)
|
|
if err != nil {
|
|
return o, errFmtf("limit download: %s", err)
|
|
}
|
|
o.LimitDownloadKBps = down
|
|
return o, nil
|
|
}
|
|
|
|
// parsePosInt turns a possibly-empty string into *int. Empty → nil
|
|
// (no value). Non-empty must parse as a positive int.
|
|
func parsePosInt(raw string) (*int, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return nil, errFmtf("must be a whole number")
|
|
}
|
|
if v < 0 {
|
|
return nil, errFmtf("must be non-negative")
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
func intStringPtr(p *int) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(*p)
|
|
}
|
|
|
|
// uiErrorMessage maps the JSON-API validation codes to operator-
|
|
// friendly banner text.
|
|
func uiErrorMessage(code, msg string) string {
|
|
switch code {
|
|
case "missing_cron_expr":
|
|
return "Cron expression is required."
|
|
case "invalid_cron_expr":
|
|
return "Cron expression doesn't parse: " + msg
|
|
case "missing_paths":
|
|
return "At least one backup path is required (one per line)."
|
|
case "invalid_kind":
|
|
return "Unsupported schedule kind."
|
|
default:
|
|
return msg
|
|
}
|
|
}
|
|
|
|
// errFmtf wraps fmt.Errorf so the validators read consistently.
|
|
func errFmtf(format string, args ...any) error {
|
|
return fmt.Errorf(format, args...)
|
|
}
|