check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never, so nothing that reads a target parses it. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string. Model: opus-5-5
168 lines
4.4 KiB
Go
168 lines
4.4 KiB
Go
package archive
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// The archive rotations: how often a database target starts a new
|
|
// archive file. Every rotation but none puts the period of an event's
|
|
// receive time, in UTC, in the name of the file the event goes to,
|
|
// written in the layout beside it.
|
|
const (
|
|
archiveRotationNone = "none"
|
|
archiveRotationMonthly = "monthly"
|
|
archiveRotationDaily = "daily"
|
|
archiveRotationHourly = "hourly"
|
|
|
|
archiveMonthLayout = "2006-01"
|
|
archiveDayLayout = "2006-01-02"
|
|
archiveHourLayout = "2006-01-02-15"
|
|
)
|
|
|
|
// errArchiveRotationUnknown is returned for a rotation that is not one
|
|
// of the four.
|
|
var errArchiveRotationUnknown = errors.New(
|
|
"rotation must be none, monthly, daily or hourly",
|
|
)
|
|
|
|
// archiveFile is one of a database target's archive files, and the
|
|
// period in its name: "" for the file named without one.
|
|
type archiveFile struct {
|
|
path string
|
|
period string
|
|
}
|
|
|
|
// ValidateRotation checks a user-supplied archive rotation for
|
|
// a database target: empty or none (both meaning one file), monthly,
|
|
// daily or hourly.
|
|
func ValidateRotation(rotation string) error {
|
|
switch rotation {
|
|
case "", archiveRotationNone, archiveRotationMonthly,
|
|
archiveRotationDaily, archiveRotationHourly:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
|
|
}
|
|
}
|
|
|
|
// archivePeriod returns the period, in UTC, that a rotation puts an
|
|
// event received at receivedAt in: "2026-10" for monthly,
|
|
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none
|
|
// or an empty rotation.
|
|
func archivePeriod(rotation string, receivedAt time.Time) string {
|
|
switch rotation {
|
|
case archiveRotationMonthly:
|
|
return receivedAt.UTC().Format(archiveMonthLayout)
|
|
case archiveRotationDaily:
|
|
return receivedAt.UTC().Format(archiveDayLayout)
|
|
case archiveRotationHourly:
|
|
return receivedAt.UTC().Format(archiveHourLayout)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// archivePeriodPath returns the path of a database target's archive
|
|
// file for a period: path, as Path gives it, with "-" and the
|
|
// period put before its ".db". The period "" gives path itself.
|
|
func archivePeriodPath(path, period string) string {
|
|
if period == "" {
|
|
return path
|
|
}
|
|
|
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
|
}
|
|
|
|
// PathAt returns the archive file a database target writes an
|
|
// event received at receivedAt to: Path's file, with the
|
|
// period in its name when the target rotates.
|
|
func PathAt(
|
|
dbMgr *database.WebhookDBManager,
|
|
webhook *database.Webhook,
|
|
target *database.Target,
|
|
receivedAt time.Time,
|
|
) string {
|
|
return archivePeriodPath(
|
|
Path(dbMgr, webhook, target),
|
|
archivePeriod(target.ArchiveRotation, receivedAt),
|
|
)
|
|
}
|
|
|
|
// archiveFiles lists the database target's archive files that exist,
|
|
// given the path Path gives it: the file at path, then each
|
|
// file archivePeriodPath names for path and a period, oldest period
|
|
// first. Which rotation wrote a file does not matter, so the files of
|
|
// an earlier rotation setting are listed too.
|
|
func archiveFiles(path string) ([]archiveFile, error) {
|
|
dir := filepath.Dir(path)
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing archive files: %w", err)
|
|
}
|
|
|
|
stem := strings.TrimSuffix(filepath.Base(path), ".db")
|
|
|
|
var files []archiveFile
|
|
|
|
for _, entry := range entries {
|
|
period, ok := archiveFilePeriod(stem, entry.Name())
|
|
if ok {
|
|
files = append(files, archiveFile{
|
|
path: filepath.Join(dir, entry.Name()),
|
|
period: period,
|
|
})
|
|
}
|
|
}
|
|
|
|
// A month sorts before the days and hours in it.
|
|
slices.SortFunc(files, func(a, b archiveFile) int {
|
|
return strings.Compare(a.period, b.period)
|
|
})
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// archiveFilePeriod reports whether name is the name of an archive
|
|
// file of the target whose file name without a period is stem+".db",
|
|
// and the period in it.
|
|
func archiveFilePeriod(stem, name string) (string, bool) {
|
|
rest, ok := strings.CutPrefix(name, stem)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
rest, ok = strings.CutSuffix(rest, ".db")
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
if rest == "" {
|
|
return "", true
|
|
}
|
|
|
|
period, ok := strings.CutPrefix(rest, "-")
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
for _, layout := range []string{
|
|
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
|
|
} {
|
|
_, err := time.Parse(layout, period)
|
|
if err == nil {
|
|
return period, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|