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

Merged
lerko merged 1 commits from feat/config-as-code into develop 2026-05-16 01:10:29 +00:00
14 changed files with 1674 additions and 9 deletions
Showing only changes of commit 5b01b9ee30 - Show all commits
+104 -7
View File
@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"go-upkeep/internal/cluster"
"go-upkeep/internal/config"
"go-upkeep/internal/importer"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
@@ -27,6 +28,102 @@ import (
func main() {
log.SetOutput(os.Stderr)
if len(os.Args) >= 2 {
switch os.Args[1] {
case "apply":
runApply(os.Args[2:])
return
case "export":
runExport(os.Args[2:])
return
}
}
runServe(os.Args[1:])
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func openStore(dbType, dsn string) store.Store {
var s store.Store
var err error
if dbType == "postgres" {
s, err = store.NewPostgresStore(dsn)
} else {
s, err = store.NewSQLiteStore(dsn)
}
if err != nil {
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1)
}
if err := s.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
return s
}
func runApply(args []string) {
fs := flag.NewFlagSet("apply", flag.ExitOnError)
filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args)
if *filePath == "" {
fmt.Fprintln(os.Stderr, "error: -f flag is required")
fs.Usage()
os.Exit(1)
}
s := openStore(*dbType, *dsn)
f, err := config.LoadFile(*filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
changes, err := config.Apply(s, f, config.ApplyOpts{
DryRun: *dryRun,
Prune: *prune,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Print(config.FormatChanges(changes, *dryRun))
}
func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args)
s := openStore(*dbType, *dsn)
f, err := config.Export(s)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := config.WriteFile(f, *outPath); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func runServe(args []string) {
portVal := 23234
dbType := "sqlite"
dbDSN := "upkeep.db"
@@ -59,7 +156,6 @@ func main() {
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" {
statusTitle = v
}
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" {
clusterMode = v
}
@@ -70,12 +166,13 @@ func main() {
clusterKey = v
}
port := flag.Int("port", portVal, "SSH Port")
flagDBType := flag.String("db-type", dbType, "Database type")
flagDSN := flag.String("dsn", dbDSN, "Database DSN")
demo := flag.Bool("demo", false, "Seed demo data")
importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
flag.Parse()
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", portVal, "SSH Port")
flagDBType := fs.String("db-type", dbType, "Database type")
flagDSN := fs.String("dsn", dbDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
fs.Parse(args)
var s store.Store
var dbErr error
+244
View File
@@ -0,0 +1,244 @@
# Config as Code
Define your monitors and alerts in a YAML file. Version control them, copy them between instances, or spin up a fresh setup in one command.
## Quick start
Export what you already have:
```bash
goupkeep export -o monitors.yaml
```
That gives you a working file you can edit and re-apply:
```bash
goupkeep apply -f monitors.yaml
```
That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
## The YAML file
Two top-level sections: `alerts` and `monitors`. Alerts go first because monitors reference them by name.
```yaml
alerts:
- name: Discord Ops
type: discord
settings:
url: https://discord.com/api/webhooks/your/token
- name: PagerDuty Critical
type: pagerduty
settings:
routing_key: your-integration-key
severity: critical
monitors:
- name: API
type: http
url: https://api.example.com/health
interval: 30
alert: Discord Ops
- name: Production
type: group
alert: PagerDuty Critical
monitors:
- name: Prod Web
type: http
url: https://prod.example.com
interval: 15
- name: Prod DB
type: port
hostname: db.internal
port: 5432
interval: 30
```
## Monitor types
Each type has required fields. Everything else is optional with sensible defaults.
**http** — polls a URL
```yaml
- name: My API
type: http
url: https://api.example.com/health
interval: 30
```
Optional: `method` (default GET), `accepted_codes` (default 200-299), `timeout`, `check_ssl`, `expiry_threshold` (default 7 days), `max_retries`, `ignore_tls`, `description`, `paused`.
**ping** — ICMP ping a host
```yaml
- name: Gateway
type: ping
hostname: 10.0.0.1
interval: 30
```
**port** — check if a port is open
```yaml
- name: SSH Server
type: port
hostname: 10.0.0.1
port: 22
interval: 60
```
**dns** — resolve a hostname
```yaml
- name: DNS Check
type: dns
hostname: example.com
dns_resolve_type: A
dns_server: 1.1.1.1
interval: 60
```
**push** — heartbeat endpoint for cron jobs
```yaml
- name: Nightly Backup
type: push
interval: 86400
```
Push monitors get a token assigned automatically. Hit the push endpoint before the interval expires or it alerts.
**group** — organize monitors together
```yaml
- name: Production
type: group
monitors:
- name: Web
type: http
url: https://prod.example.com
interval: 15
```
Groups can't nest inside other groups. A group is healthy when all its children are healthy.
## Alert types
All 9 providers work in the YAML. The `settings` map is different per type.
```yaml
# Discord / Slack / Generic Webhook — just a URL
- name: Discord Ops
type: discord
settings:
url: https://discord.com/api/webhooks/your/token
# Email
- name: Email Oncall
type: email
settings:
host: smtp.example.com
port: "587"
user: oncall@example.com
pass: your-password
from: oncall@example.com
to: team@example.com
# Ntfy
- name: Ntfy Alerts
type: ntfy
settings:
url: https://ntfy.sh
topic: my-alerts
priority: "4"
# Telegram
- name: Telegram Ops
type: telegram
settings:
token: "123456:ABC-DEF..."
chat_id: "-1001234567890"
# PagerDuty
- name: PD Critical
type: pagerduty
settings:
routing_key: your-integration-key
severity: critical
# Pushover
- name: Pushover
type: pushover
settings:
token: app-token
user: user-key
# Gotify
- name: Gotify
type: gotify
settings:
url: https://gotify.example.com
token: app-token
priority: "8"
```
## Commands
**Export current state:**
```bash
goupkeep export -o monitors.yaml # to a file
goupkeep export # to stdout
```
**Apply a config:**
```bash
goupkeep apply -f monitors.yaml
```
**See what would change first:**
```bash
goupkeep apply -f monitors.yaml --dry-run
```
**Delete monitors not in the YAML:**
```bash
goupkeep apply -f monitors.yaml --prune
```
Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:**
```bash
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable"
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..."
```
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too.
## How apply works
Monitors and alerts are matched by **name**. Names must be unique across the entire file.
1. Alerts are resolved first (created or updated)
2. Groups are created next (so children can reference them)
3. Everything else is created or updated
4. If `--prune` is set, anything in the database that's not in the YAML gets deleted
Apply is idempotent. Run it twice with the same file, second run changes nothing.
If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
## Typical workflow
```bash
# set up your monitors in the TUI first, then export
goupkeep export -o monitors.yaml
# commit it
git add monitors.yaml && git commit -m "add monitor config"
# deploy to another instance
scp monitors.yaml prod-server:
ssh prod-server goupkeep apply -f monitors.yaml
# or just keep it as a backup you can restore from
goupkeep apply -f monitors.yaml
```
+1
View File
@@ -57,4 +57,5 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+1
View File
@@ -121,5 +121,6 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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