From 5b01b9ee309160919f1a1afd79fec1dd08985707 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 20:40:49 -0400 Subject: [PATCH] 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 --- cmd/goupkeep/main.go | 111 +++++++- docs/config-as-code.md | 244 +++++++++++++++++ go.mod | 1 + go.sum | 1 + internal/config/apply.go | 392 ++++++++++++++++++++++++++++ internal/config/apply_test.go | 290 ++++++++++++++++++++ internal/config/export.go | 154 +++++++++++ internal/config/export_test.go | 140 ++++++++++ internal/config/types.go | 34 +++ internal/config/validate.go | 88 +++++++ internal/config/validate_test.go | 163 ++++++++++++ internal/metrics/prometheus_test.go | 12 +- internal/store/sqlstore.go | 47 ++++ internal/store/store.go | 6 + 14 files changed, 1674 insertions(+), 9 deletions(-) create mode 100644 docs/config-as-code.md create mode 100644 internal/config/apply.go create mode 100644 internal/config/apply_test.go create mode 100644 internal/config/export.go create mode 100644 internal/config/export_test.go create mode 100644 internal/config/types.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_test.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 5962e37..6d72682 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -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 diff --git a/docs/config-as-code.md b/docs/config-as-code.md new file mode 100644 index 0000000..1c9e8a1 --- /dev/null +++ b/docs/config-as-code.md @@ -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 +``` diff --git a/go.mod b/go.mod index 7011fb1..38cc730 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 88b63c7..17c3eca 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/apply.go b/internal/config/apply.go new file mode 100644 index 0000000..2c81abf --- /dev/null +++ b/internal/config/apply.go @@ -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() +} diff --git a/internal/config/apply_test.go b/internal/config/apply_test.go new file mode 100644 index 0000000..824fd61 --- /dev/null +++ b/internal/config/apply_test.go @@ -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") + } +} diff --git a/internal/config/export.go b/internal/config/export.go new file mode 100644 index 0000000..a6d182d --- /dev/null +++ b/internal/config/export.go @@ -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 +} diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..16b5534 --- /dev/null +++ b/internal/config/export_test.go @@ -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) + } + } +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..ed0895f --- /dev/null +++ b/internal/config/types.go @@ -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"` +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..e9a2f2d --- /dev/null +++ b/internal/config/validate.go @@ -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 +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..fdcfd42 --- /dev/null +++ b/internal/config/validate_test.go @@ -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) + } +} diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go index 7cbf680..091a5df 100644 --- a/internal/metrics/prometheus_test.go +++ b/internal/metrics/prometheus_test.go @@ -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{ diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 3142399..8adc020 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -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 { diff --git a/internal/store/store.go b/internal/store/store.go index 35afa0b..1ed3c99 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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