feat(config): add config-as-code YAML import/export
Add declarative config-as-code support via YAML files. Monitors and alerts can be exported, version controlled, and applied across instances. - goupkeep export [-o file.yaml] dumps current state - goupkeep apply -f file.yaml creates/updates to match desired state - --dry-run shows planned changes without applying - --prune deletes monitors/alerts not in the YAML - Matching by name, alert references by name, nested group children - CLI refactored to subcommands (apply, export, serve) with backward compat - 24 tests covering apply, export, validation, round-trip idempotency
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user