package config import ( "fmt" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/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, Regions: m.Regions, } 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)) } if existing.Regions != desired.Regions { diffs = append(diffs, fmt.Sprintf("regions: %s -> %s", existing.Regions, desired.Regions)) } 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() }