feat(config): add config-as-code YAML import/export

Add declarative config-as-code support via YAML files. Monitors and
alerts can be exported, version controlled, and applied across instances.

- goupkeep export [-o file.yaml] dumps current state
- goupkeep apply -f file.yaml creates/updates to match desired state
- --dry-run shows planned changes without applying
- --prune deletes monitors/alerts not in the YAML
- Matching by name, alert references by name, nested group children
- CLI refactored to subcommands (apply, export, serve) with backward compat
- 24 tests covering apply, export, validation, round-trip idempotency
This commit is contained in:
2026-05-15 20:40:49 -04:00
parent 5a52f738db
commit 5b01b9ee30
14 changed files with 1674 additions and 9 deletions
+392
View File
@@ -0,0 +1,392 @@
package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"reflect"
"strings"
)
type ApplyOpts struct {
DryRun bool
Prune bool
}
type Change struct {
Action string
Kind string
Name string
Details string
}
func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
if err := Validate(f); err != nil {
return nil, err
}
existingAlerts, err := s.GetAllAlerts()
if err != nil {
return nil, fmt.Errorf("load alerts: %w", err)
}
existingSites, err := s.GetSites()
if err != nil {
return nil, fmt.Errorf("load sites: %w", err)
}
existingAlertsByName := make(map[string]models.AlertConfig, len(existingAlerts))
for _, a := range existingAlerts {
existingAlertsByName[a.Name] = a
}
existingSitesByName := make(map[string]models.Site, len(existingSites))
for _, s := range existingSites {
existingSitesByName[s.Name] = s
}
var changes []Change
alertMap := make(map[string]int)
for _, ea := range existingAlerts {
alertMap[ea.Name] = ea.ID
}
desiredAlertNames := make(map[string]bool, len(f.Alerts))
for _, a := range f.Alerts {
desiredAlertNames[a.Name] = true
existing, exists := existingAlertsByName[a.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type})
if !opts.DryRun {
id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings)
if err != nil {
return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
}
alertMap[a.Name] = id
}
} else {
alertMap[a.Name] = existing.ID
if diff := diffAlert(existing, a); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff})
if !opts.DryRun {
if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil {
return changes, fmt.Errorf("update alert %q: %w", a.Name, err)
}
}
}
}
}
desiredMonitorNames := make(map[string]bool)
collectMonitorNames(f.Monitors, desiredMonitorNames)
var groups []Monitor
var topLevel []Monitor
for _, m := range f.Monitors {
if m.Type == "group" {
groups = append(groups, m)
} else {
topLevel = append(topLevel, m)
}
}
groupMap := make(map[string]int)
for _, g := range groups {
alertID, err := resolveAlertID(alertMap, g.Alert)
if err != nil {
return changes, fmt.Errorf("monitor %q: %w", g.Name, err)
}
site := monitorToSite(g, alertID, 0)
existing, exists := existingSitesByName[g.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"})
if !opts.DryRun {
id, err := s.AddSiteReturningID(site)
if err != nil {
return changes, fmt.Errorf("create group %q: %w", g.Name, err)
}
groupMap[g.Name] = id
}
} else {
groupMap[g.Name] = existing.ID
site.ID = existing.ID
if diff := diffSite(normalizeSite(existing), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff})
if !opts.DryRun {
if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update group %q: %w", g.Name, err)
}
}
}
}
}
for _, g := range groups {
parentID := groupMap[g.Name]
for _, child := range g.Monitors {
c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
if err != nil {
return changes, err
}
changes = append(changes, c...)
}
}
for _, m := range topLevel {
c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun)
if err != nil {
return changes, err
}
changes = append(changes, c...)
}
if opts.Prune {
var childDeletes []Change
var groupDeletes []Change
for _, es := range existingSites {
if desiredMonitorNames[es.Name] {
continue
}
c := Change{Action: "delete", Kind: "monitor", Name: es.Name, Details: es.Type}
if es.Type == "group" {
groupDeletes = append(groupDeletes, c)
} else {
childDeletes = append(childDeletes, c)
}
if !opts.DryRun {
if err := s.DeleteSite(es.ID); err != nil {
return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err)
}
}
}
changes = append(changes, childDeletes...)
changes = append(changes, groupDeletes...)
for _, ea := range existingAlerts {
if desiredAlertNames[ea.Name] {
continue
}
changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type})
if !opts.DryRun {
if err := s.DeleteAlert(ea.ID); err != nil {
return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err)
}
}
}
}
return changes, nil
}
func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) {
alertID, err := resolveAlertID(alertMap, m.Alert)
if err != nil {
return nil, fmt.Errorf("monitor %q: %w", m.Name, err)
}
site := monitorToSite(m, alertID, parentID)
var changes []Change
ex, exists := existing[m.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type})
if !dryRun {
if _, err := s.AddSiteReturningID(site); err != nil {
return changes, fmt.Errorf("create monitor %q: %w", m.Name, err)
}
}
} else {
site.ID = ex.ID
if diff := diffSite(normalizeSite(ex), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff})
if !dryRun {
if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update monitor %q: %w", m.Name, err)
}
}
}
}
return changes, nil
}
func resolveAlertID(alertMap map[string]int, name string) (int, error) {
if name == "" {
return 0, nil
}
id, ok := alertMap[name]
if !ok {
return 0, fmt.Errorf("alert %q not found", name)
}
return id, nil
}
func monitorToSite(m Monitor, alertID, parentID int) models.Site {
s := models.Site{
Name: m.Name,
Type: m.Type,
URL: m.URL,
Interval: m.Interval,
AlertID: alertID,
ParentID: parentID,
CheckSSL: m.CheckSSL,
MaxRetries: m.MaxRetries,
Hostname: m.Hostname,
Port: m.Port,
Timeout: m.Timeout,
Description: m.Description,
DNSResolveType: m.DNSResolveType,
DNSServer: m.DNSServer,
IgnoreTLS: m.IgnoreTLS,
Paused: m.Paused,
}
s.ExpiryThreshold = m.ExpiryThreshold
if s.ExpiryThreshold == 0 {
s.ExpiryThreshold = 7
}
s.Method = m.Method
if s.Method == "" {
s.Method = "GET"
}
s.AcceptedCodes = m.AcceptedCodes
if s.AcceptedCodes == "" {
s.AcceptedCodes = "200-299"
}
return s
}
func collectMonitorNames(monitors []Monitor, names map[string]bool) {
for _, m := range monitors {
names[m.Name] = true
collectMonitorNames(m.Monitors, names)
}
}
func normalizeSite(s models.Site) models.Site {
if s.Method == "" {
s.Method = "GET"
}
if s.AcceptedCodes == "" {
s.AcceptedCodes = "200-299"
}
if s.ExpiryThreshold == 0 {
s.ExpiryThreshold = 7
}
return s
}
func diffAlert(existing models.AlertConfig, desired Alert) string {
var diffs []string
if existing.Type != desired.Type {
diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type))
}
if !reflect.DeepEqual(existing.Settings, desired.Settings) {
diffs = append(diffs, "settings changed")
}
return strings.Join(diffs, ", ")
}
func diffSite(existing, desired models.Site) string {
var diffs []string
if existing.URL != desired.URL {
diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL))
}
if existing.Type != desired.Type {
diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type))
}
if existing.Interval != desired.Interval {
diffs = append(diffs, fmt.Sprintf("interval: %d -> %d", existing.Interval, desired.Interval))
}
if existing.AlertID != desired.AlertID {
diffs = append(diffs, fmt.Sprintf("alert_id: %d -> %d", existing.AlertID, desired.AlertID))
}
if existing.CheckSSL != desired.CheckSSL {
diffs = append(diffs, fmt.Sprintf("check_ssl: %v -> %v", existing.CheckSSL, desired.CheckSSL))
}
if existing.ExpiryThreshold != desired.ExpiryThreshold {
diffs = append(diffs, fmt.Sprintf("expiry_threshold: %d -> %d", existing.ExpiryThreshold, desired.ExpiryThreshold))
}
if existing.MaxRetries != desired.MaxRetries {
diffs = append(diffs, fmt.Sprintf("max_retries: %d -> %d", existing.MaxRetries, desired.MaxRetries))
}
if existing.Hostname != desired.Hostname {
diffs = append(diffs, fmt.Sprintf("hostname: %s -> %s", existing.Hostname, desired.Hostname))
}
if existing.Port != desired.Port {
diffs = append(diffs, fmt.Sprintf("port: %d -> %d", existing.Port, desired.Port))
}
if existing.Timeout != desired.Timeout {
diffs = append(diffs, fmt.Sprintf("timeout: %d -> %d", existing.Timeout, desired.Timeout))
}
if existing.Method != desired.Method {
diffs = append(diffs, fmt.Sprintf("method: %s -> %s", existing.Method, desired.Method))
}
if existing.Description != desired.Description {
diffs = append(diffs, "description changed")
}
if existing.ParentID != desired.ParentID {
diffs = append(diffs, fmt.Sprintf("parent_id: %d -> %d", existing.ParentID, desired.ParentID))
}
if existing.AcceptedCodes != desired.AcceptedCodes {
diffs = append(diffs, fmt.Sprintf("accepted_codes: %s -> %s", existing.AcceptedCodes, desired.AcceptedCodes))
}
if existing.DNSResolveType != desired.DNSResolveType {
diffs = append(diffs, fmt.Sprintf("dns_resolve_type: %s -> %s", existing.DNSResolveType, desired.DNSResolveType))
}
if existing.DNSServer != desired.DNSServer {
diffs = append(diffs, fmt.Sprintf("dns_server: %s -> %s", existing.DNSServer, desired.DNSServer))
}
if existing.IgnoreTLS != desired.IgnoreTLS {
diffs = append(diffs, fmt.Sprintf("ignore_tls: %v -> %v", existing.IgnoreTLS, desired.IgnoreTLS))
}
if existing.Paused != desired.Paused {
diffs = append(diffs, fmt.Sprintf("paused: %v -> %v", existing.Paused, desired.Paused))
}
return strings.Join(diffs, ", ")
}
func FormatChanges(changes []Change, dryRun bool) string {
var b strings.Builder
if dryRun {
b.WriteString("Dry run — no changes applied.\n\n")
}
if len(changes) == 0 {
b.WriteString("No changes needed. State is up to date.\n")
return b.String()
}
creates, updates, deletes := 0, 0, 0
for _, c := range changes {
var prefix string
switch c.Action {
case "create":
prefix = " + create"
creates++
case "update":
prefix = " ~ update"
updates++
case "delete":
prefix = " - delete"
deletes++
}
line := fmt.Sprintf("%s %s %q", prefix, c.Kind, c.Name)
if c.Details != "" {
line += " (" + c.Details + ")"
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
if dryRun {
fmt.Fprintf(&b, "Summary: %d to create, %d to update, %d to delete\n", creates, updates, deletes)
} else {
total := creates + updates + deletes
fmt.Fprintf(&b, "Applied %d changes (%d created, %d updated, %d deleted)\n", total, creates, updates, deletes)
}
return b.String()
}
+290
View File
@@ -0,0 +1,290 @@
package config
import (
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"strings"
"testing"
)
func newTestStore(t *testing.T) store.Store {
t.Helper()
s, err := store.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return s
}
func TestApplyCreateFromScratch(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 3 {
t.Fatalf("expected 3 creates, got %d", creates)
}
sites, _ := s.GetSites()
if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites))
}
alerts, _ := s.GetAllAlerts()
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
}
func TestApplyIdempotent(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err)
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
if len(changes) != 0 {
t.Fatalf("expected 0 changes on second apply, got %d: %+v", len(changes), changes)
}
}
func TestApplyUpdate(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err)
}
f.Monitors[0].Interval = 60
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("expected 1 update, got %+v", changes)
}
sites, _ := s.GetSites()
if sites[0].Interval != 60 {
t.Fatalf("expected interval 60, got %d", sites[0].Interval)
}
}
func TestApplyPrune(t *testing.T) {
s := newTestStore(t)
s.AddSite(models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f := &File{
Monitors: []Monitor{
{Name: "Keep", Type: "http", URL: "https://keep.com", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{Prune: true})
if err != nil {
t.Fatalf("Apply: %v", err)
}
deleteCount := 0
for _, c := range changes {
if c.Action == "delete" {
deleteCount++
}
}
if deleteCount != 1 {
t.Fatalf("expected 1 delete, got %d", deleteCount)
}
sites, _ := s.GetSites()
if len(sites) != 1 || sites[0].Name != "Keep" {
t.Fatalf("expected only 'Keep', got %+v", sites)
}
}
func TestApplyDryRun(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("expected 1 create in dry-run, got %+v", changes)
}
sites, _ := s.GetSites()
if len(sites) != 0 {
t.Fatalf("expected 0 sites after dry-run, got %d", len(sites))
}
}
func TestApplyGroupHierarchy(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15},
{Name: "Prod DB", Type: "port", Hostname: "db.internal", Port: 5432, Interval: 30},
},
},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 3 {
t.Fatalf("expected 3 creates, got %d", len(changes))
}
sites, _ := s.GetSites()
var group models.Site
for _, s := range sites {
if s.Type == "group" {
group = s
break
}
}
if group.ID == 0 {
t.Fatal("group not found")
}
childCount := 0
for _, s := range sites {
if s.ParentID == group.ID {
childCount++
}
}
if childCount != 2 {
t.Fatalf("expected 2 children, got %d", childCount)
}
}
func TestApplyAlertReference(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("Apply: %v", err)
}
sites, _ := s.GetSites()
alerts, _ := s.GetAllAlerts()
if sites[0].AlertID != alerts[0].ID {
t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID)
}
}
func TestApplyInvalidAlertRef(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Nonexistent"},
},
}
_, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected alert not found error, got %v", err)
}
}
func TestApplyDuplicateNames(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://a.com", Interval: 30},
{Name: "Web", Type: "http", URL: "https://b.com", Interval: 30},
},
}
_, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate error, got %v", err)
}
}
func TestApplyExistingAlertReference(t *testing.T) {
s := newTestStore(t)
s.AddAlert("Existing", "webhook", map[string]string{"url": "https://example.com"})
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Existing"},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("expected 1 create, got %+v", changes)
}
sites, _ := s.GetSites()
if sites[0].AlertID == 0 {
t.Fatal("expected non-zero alert_id for existing alert reference")
}
}
+154
View File
@@ -0,0 +1,154 @@
package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"os"
"sort"
"gopkg.in/yaml.v3"
)
func Export(s store.Store) (*File, error) {
dbAlerts, err := s.GetAllAlerts()
if err != nil {
return nil, fmt.Errorf("load alerts: %w", err)
}
dbSites, err := s.GetSites()
if err != nil {
return nil, fmt.Errorf("load sites: %w", err)
}
alertIDToName := make(map[int]string, len(dbAlerts))
var yamlAlerts []Alert
for _, a := range dbAlerts {
alertIDToName[a.ID] = a.Name
yamlAlerts = append(yamlAlerts, Alert{
Name: a.Name,
Type: a.Type,
Settings: a.Settings,
})
}
groups := make(map[int]models.Site)
children := make(map[int][]models.Site)
var topLevel []models.Site
for _, s := range dbSites {
switch {
case s.Type == "group":
groups[s.ID] = s
case s.ParentID > 0:
children[s.ParentID] = append(children[s.ParentID], s)
default:
topLevel = append(topLevel, s)
}
}
var yamlMonitors []Monitor
groupIDs := make([]int, 0, len(groups))
for id := range groups {
groupIDs = append(groupIDs, id)
}
sort.Ints(groupIDs)
for _, gid := range groupIDs {
g := groups[gid]
ym := siteToMonitor(g, alertIDToName)
kids := children[gid]
sort.Slice(kids, func(i, j int) bool { return kids[i].ID < kids[j].ID })
for _, child := range kids {
ym.Monitors = append(ym.Monitors, siteToMonitor(child, alertIDToName))
}
yamlMonitors = append(yamlMonitors, ym)
}
sort.Slice(topLevel, func(i, j int) bool { return topLevel[i].ID < topLevel[j].ID })
for _, s := range topLevel {
yamlMonitors = append(yamlMonitors, siteToMonitor(s, alertIDToName))
}
return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil
}
func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor {
m := Monitor{
Name: s.Name,
Type: s.Type,
Interval: s.Interval,
}
if s.AlertID > 0 {
if name, ok := alertIDToName[s.AlertID]; ok {
m.Alert = name
}
}
if s.URL != "" {
m.URL = s.URL
}
if s.Hostname != "" {
m.Hostname = s.Hostname
}
if s.Port != 0 {
m.Port = s.Port
}
if s.Timeout != 0 {
m.Timeout = s.Timeout
}
if s.Description != "" {
m.Description = s.Description
}
if s.DNSResolveType != "" {
m.DNSResolveType = s.DNSResolveType
}
if s.DNSServer != "" {
m.DNSServer = s.DNSServer
}
if s.Method != "" && s.Method != "GET" {
m.Method = s.Method
}
if s.AcceptedCodes != "" && s.AcceptedCodes != "200-299" {
m.AcceptedCodes = s.AcceptedCodes
}
if s.ExpiryThreshold != 0 && s.ExpiryThreshold != 7 {
m.ExpiryThreshold = s.ExpiryThreshold
}
if s.MaxRetries != 0 {
m.MaxRetries = s.MaxRetries
}
m.CheckSSL = s.CheckSSL
m.IgnoreTLS = s.IgnoreTLS
m.Paused = s.Paused
return m
}
func WriteFile(f *File, path string) error {
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshal yaml: %w", err)
}
if path == "-" || path == "" {
_, err = os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0644)
}
func LoadFile(path string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var f File
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &f, nil
}
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"go-upkeep/internal/models"
"testing"
)
func TestExportEmpty(t *testing.T) {
s := newTestStore(t)
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Alerts) != 0 || len(f.Monitors) != 0 {
t.Fatalf("expected empty file, got %d alerts %d monitors", len(f.Alerts), len(f.Monitors))
}
}
func TestExportAlertNames(t *testing.T) {
s := newTestStore(t)
s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s.GetAllAlerts()
s.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Monitors) != 1 {
t.Fatalf("expected 1 monitor, got %d", len(f.Monitors))
}
if f.Monitors[0].Alert != "Discord" {
t.Fatalf("expected alert name 'Discord', got %q", f.Monitors[0].Alert)
}
}
func TestExportGroupHierarchy(t *testing.T) {
s := newTestStore(t)
groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Monitors) != 2 {
t.Fatalf("expected 2 top-level monitors, got %d", len(f.Monitors))
}
var group *Monitor
for i := range f.Monitors {
if f.Monitors[i].Type == "group" {
group = &f.Monitors[i]
break
}
}
if group == nil {
t.Fatal("group not found in export")
}
if len(group.Monitors) != 1 {
t.Fatalf("expected 1 child in group, got %d", len(group.Monitors))
}
if group.Monitors[0].Name != "Prod Web" {
t.Fatalf("expected child 'Prod Web', got %q", group.Monitors[0].Name)
}
}
func TestExportOmitsDefaults(t *testing.T) {
s := newTestStore(t)
s.AddSite(models.Site{
Name: "Web", URL: "https://example.com", Type: "http", Interval: 30,
Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7,
})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
m := f.Monitors[0]
if m.Method != "" {
t.Errorf("expected empty method (default omitted), got %q", m.Method)
}
if m.AcceptedCodes != "" {
t.Errorf("expected empty accepted_codes (default omitted), got %q", m.AcceptedCodes)
}
if m.ExpiryThreshold != 0 {
t.Errorf("expected 0 expiry_threshold (default omitted), got %d", m.ExpiryThreshold)
}
}
func TestExportRoundTrip(t *testing.T) {
s1 := newTestStore(t)
s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s1.GetAllAlerts()
s1.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s1.AddSite(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
exported, err := Export(s1)
if err != nil {
t.Fatalf("Export: %v", err)
}
s2 := newTestStore(t)
changes, err := Apply(s2, exported, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 3 {
t.Fatalf("expected 3 creates, got %d", creates)
}
reexported, err := Export(s2)
if err != nil {
t.Fatalf("re-Export: %v", err)
}
if len(reexported.Alerts) != len(exported.Alerts) {
t.Fatalf("alert count mismatch: %d vs %d", len(reexported.Alerts), len(exported.Alerts))
}
if len(reexported.Monitors) != len(exported.Monitors) {
t.Fatalf("monitor count mismatch: %d vs %d", len(reexported.Monitors), len(exported.Monitors))
}
for i, m := range reexported.Monitors {
if m.Name != exported.Monitors[i].Name {
t.Errorf("monitor %d name mismatch: %q vs %q", i, m.Name, exported.Monitors[i].Name)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package config
type File struct {
Alerts []Alert `yaml:"alerts,omitempty"`
Monitors []Monitor `yaml:"monitors,omitempty"`
}
type Alert struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Settings map[string]string `yaml:"settings"`
}
type Monitor struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
URL string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Alert string `yaml:"alert,omitempty"`
CheckSSL bool `yaml:"check_ssl,omitempty"`
ExpiryThreshold int `yaml:"expiry_threshold,omitempty"`
MaxRetries int `yaml:"max_retries,omitempty"`
Hostname string `yaml:"hostname,omitempty"`
Port int `yaml:"port,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
Method string `yaml:"method,omitempty"`
Description string `yaml:"description,omitempty"`
AcceptedCodes string `yaml:"accepted_codes,omitempty"`
DNSResolveType string `yaml:"dns_resolve_type,omitempty"`
DNSServer string `yaml:"dns_server,omitempty"`
IgnoreTLS bool `yaml:"ignore_tls,omitempty"`
Paused bool `yaml:"paused,omitempty"`
Monitors []Monitor `yaml:"monitors,omitempty"`
}
+88
View File
@@ -0,0 +1,88 @@
package config
import "fmt"
var validMonitorTypes = map[string]bool{
"http": true,
"push": true,
"ping": true,
"port": true,
"dns": true,
"group": true,
}
func Validate(f *File) error {
alertNames := make(map[string]bool, len(f.Alerts))
for _, a := range f.Alerts {
if a.Name == "" {
return fmt.Errorf("alert missing name")
}
if alertNames[a.Name] {
return fmt.Errorf("duplicate alert name %q", a.Name)
}
alertNames[a.Name] = true
if a.Type == "" {
return fmt.Errorf("alert %q: missing type", a.Name)
}
}
monitorNames := make(map[string]bool)
for _, m := range f.Monitors {
if err := validateMonitor(m, monitorNames, false); err != nil {
return err
}
}
return nil
}
func validateMonitor(m Monitor, names map[string]bool, nested bool) error {
if m.Name == "" {
return fmt.Errorf("monitor missing name")
}
if names[m.Name] {
return fmt.Errorf("duplicate monitor name %q", m.Name)
}
names[m.Name] = true
if !validMonitorTypes[m.Type] {
return fmt.Errorf("monitor %q: invalid type %q", m.Name, m.Type)
}
if m.Type == "group" && nested {
return fmt.Errorf("monitor %q: groups cannot be nested inside other groups", m.Name)
}
switch m.Type {
case "http":
if m.URL == "" {
return fmt.Errorf("monitor %q: url is required for type http", m.Name)
}
case "ping":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type ping", m.Name)
}
case "port":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type port", m.Name)
}
if m.Port == 0 {
return fmt.Errorf("monitor %q: port is required for type port", m.Name)
}
case "dns":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type dns", m.Name)
}
}
if m.Type == "group" {
for _, child := range m.Monitors {
if err := validateMonitor(child, names, true); err != nil {
return err
}
}
} else if len(m.Monitors) > 0 {
return fmt.Errorf("monitor %q: only groups can have nested monitors", m.Name)
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
package config
import (
"strings"
"testing"
)
func TestValidateDuplicateAlertNames(t *testing.T) {
f := &File{
Alerts: []Alert{
{Name: "A", Type: "discord"},
{Name: "A", Type: "slack"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate alert name") {
t.Fatalf("expected duplicate alert error, got %v", err)
}
}
func TestValidateDuplicateMonitorNames(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "M", Type: "http", URL: "https://example.com"},
{Name: "M", Type: "ping", Hostname: "10.0.0.1"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") {
t.Fatalf("expected duplicate monitor error, got %v", err)
}
}
func TestValidateDuplicateNameAcrossGroups(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com"},
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://prod.example.com"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") {
t.Fatalf("expected duplicate name across group, got %v", err)
}
}
func TestValidateNestedGroupReject(t *testing.T) {
f := &File{
Monitors: []Monitor{
{
Name: "Outer", Type: "group",
Monitors: []Monitor{
{Name: "Inner", Type: "group"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "cannot be nested") {
t.Fatalf("expected nested group error, got %v", err)
}
}
func TestValidateRequiredFields(t *testing.T) {
tests := []struct {
name string
monitor Monitor
wantErr string
}{
{"http no url", Monitor{Name: "A", Type: "http"}, "url is required"},
{"ping no hostname", Monitor{Name: "A", Type: "ping"}, "hostname is required"},
{"port no hostname", Monitor{Name: "A", Type: "port", Port: 22}, "hostname is required"},
{"port no port", Monitor{Name: "A", Type: "port", Hostname: "h"}, "port is required"},
{"dns no hostname", Monitor{Name: "A", Type: "dns"}, "hostname is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &File{Monitors: []Monitor{tt.monitor}}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected %q, got %v", tt.wantErr, err)
}
})
}
}
func TestValidateInvalidMonitorType(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "A", Type: "ftp"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "invalid type") {
t.Fatalf("expected invalid type error, got %v", err)
}
}
func TestValidateNonGroupWithChildren(t *testing.T) {
f := &File{
Monitors: []Monitor{
{
Name: "A", Type: "http", URL: "https://example.com",
Monitors: []Monitor{
{Name: "B", Type: "ping", Hostname: "h"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "only groups") {
t.Fatalf("expected only-groups error, got %v", err)
}
}
func TestValidateAlertMissingName(t *testing.T) {
f := &File{
Alerts: []Alert{{Type: "discord"}},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "missing name") {
t.Fatalf("expected missing name error, got %v", err)
}
}
func TestValidateAlertMissingType(t *testing.T) {
f := &File{
Alerts: []Alert{{Name: "A"}},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "missing type") {
t.Fatalf("expected missing type error, got %v", err)
}
}
func TestValidateValidConfig(t *testing.T) {
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30},
{Name: "SSH", Type: "port", Hostname: "10.0.0.1", Port: 22, Interval: 60},
{Name: "DNS", Type: "dns", Hostname: "example.com", Interval: 60},
{Name: "Cron", Type: "push", Interval: 300},
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15},
},
},
},
}
if err := Validate(f); err != nil {
t.Fatalf("expected valid config, got %v", err)
}
}
+10 -2
View File
@@ -34,8 +34,16 @@ func (m *mockStore) SaveCheck(int, int64, bool) error {
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
}
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func TestMetricsHandler(t *testing.T) {
ms := &mockStore{
+47
View File
@@ -110,6 +110,53 @@ func (s *SQLStore) DeleteSite(id int) error {
return nil
}
func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse()
query := fmt.Sprintf(
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s) FROM sites WHERE name = %s",
bf, bf, s.q("?"),
)
var st models.Site
err := s.db.QueryRow(query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
&st.DNSServer, &st.IgnoreTLS, &st.Paused)
return st, err
}
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, err
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, nil
}
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
if err := s.AddSite(site); err != nil {
return 0, err
}
created, err := s.GetSiteByName(site.Name)
if err != nil {
return 0, err
}
return created.ID, nil
}
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
if err := s.AddAlert(name, aType, settings); err != nil {
return 0, err
}
created, err := s.GetAlertByName(name)
if err != nil {
return 0, err
}
return created.ID, nil
}
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
+6
View File
@@ -21,6 +21,12 @@ type Store interface {
UpdateAlert(id int, name, aType string, settings map[string]string) error
DeleteAlert(id int) error
// Declarative config support
GetSiteByName(name string) (models.Site, error)
GetAlertByName(name string) (models.AlertConfig, error)
AddSiteReturningID(site models.Site) (int, error)
AddAlertReturningID(name, aType string, settings map[string]string) (int, error)
// Users
GetAllUsers() ([]models.User, error)
AddUser(username, publicKey, role string) error