release: 2026.05.1 — distributed probing, config-as-code, TUI polish #15
+104
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,14 @@ func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
|
||||
}
|
||||
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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user