Files
uptop/internal/config/apply.go
T
lerko 8f17deba67
CI / test (pull_request) Successful in 2m39s
CI / lint (pull_request) Successful in 1m6s
CI / vulncheck (pull_request) Successful in 46s
chore: migrate module path to lerkolabs org
Move Go module from gitea.lerkolabs.com/lerko/uptop to
gitea.lerkolabs.com/lerkolabs/uptop. Updates all imports,
go.mod, goreleaser owner, and README links.
2026-05-29 14:22:49 -04:00

397 lines
11 KiB
Go

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()
}