Files
jarl/internal/engine/diffops.go
T
adamandClaude Opus 5.5 7be0762f48 Server statistics, multi-view record commands, and rc.3 fixes
Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
  sockets and client counts against their limits (statistics channel,
  rndc status), host memory and load, and free disk space. Memory and
  CPU are kept over time as gauges (sum and sample count, averaged in
  timelines). Statistics page Server section, jarl stats server,
  GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
  change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
  "Until I resume", {"indefinitely": true}).

Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
  when given without them.
- The dashboard lists named-checkconf's warnings about the running
  configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
  failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:05 +01:00

217 lines
8.0 KiB
Go

package engine
import (
"fmt"
"reflect"
"sort"
"jarl/internal/dnsname"
"jarl/internal/model"
)
// DiffOps returns operations that turn from into to. It is used to record
// how to undo an applied changeset and to turn an imported configuration
// into a changeset. Create operations keep the object IDs of to.
func DiffOps(from, to *model.State) []model.Op {
var ops []model.Op
add := func(kind string, id int64, base int, data any, summary string) {
ops = append(ops, newOp(kind, id, base, data, summary))
}
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
}
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
}
for _, k := range sortedKeys(to.Keys) {
n := to.Keys[k]
in := TSIGInput{Name: &n.Name, Algorithm: &n.Algorithm, Secret: &n.Secret, Comment: &n.Comment}
if o, ok := from.Keys[k]; !ok {
add(OpTSIGCreate, k, 0, in, "create TSIG key "+n.Name)
} else if !sameObject(o, n) {
add(OpTSIGUpdate, k, o.Version, in, "restore TSIG key "+n.Name)
}
}
for _, k := range sortedKeys(to.ACLs) {
n := to.ACLs[k]
in := ACLInput{Name: &n.Name, Elements: model.Ptr(append([]string{}, n.Elements...)), Comment: &n.Comment}
if o, ok := from.ACLs[k]; !ok {
add(OpACLCreate, k, 0, in, "create ACL "+n.Name)
} else if !sameObject(o, n) {
add(OpACLUpdate, k, o.Version, in, "restore ACL "+n.Name)
}
}
for _, k := range sortedKeys(to.Views) {
n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else {
in.MatchRecursiveOnly = model.Opt[bool]{Set: true, Null: true}
}
if o, ok := from.Views[k]; !ok {
add(OpViewCreate, k, 0, in, "create view "+n.Name)
} else if !sameObject(o, n) {
add(OpViewUpdate, k, o.Version, in, "restore view "+n.Name)
}
}
for _, k := range sortedKeys(to.Zones) {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
}
if o, ok := from.Zones[k]; !ok {
add(OpZoneCreate, k, 0, in, "create zone "+zoneDesc(to, n))
} else if !sameObject(o, n) {
in.Name = nil // zones can't be renamed
if n.Type == model.ZonePrimary && o.SOA.Serial != s.Serial {
// Never move a serial backwards: let the normal bump
// happen instead.
in.SOA.Serial = nil
}
add(OpZoneUpdate, k, o.Version, in, "restore zone "+zoneDesc(to, n))
}
}
for _, k := range sortedKeys(to.Options) {
n := to.Options[k]
in := OptionInput{Scope: &n.Scope, ScopeID: &n.ScopeID, Name: &n.Name, Value: &n.Value, Comment: &n.Comment, Position: &n.Position}
if o, ok := from.Options[k]; !ok {
add(OpOptionCreate, k, 0, in, "set "+optionLabel(to, n)+" "+n.Value)
} else if !sameObject(o, n) {
in.Scope, in.ScopeID = nil, nil
add(OpOptionUpdate, k, o.Version, in, "restore "+optionLabel(to, n))
}
}
for _, k := range sortedKeys(to.Records) {
n := to.Records[k]
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
if n.TTL != nil {
in.TTL = model.Some(*n.TTL)
} else {
in.TTL = model.Opt[uint32]{Set: true, Null: true}
}
if o, ok := from.Records[k]; !ok {
add(OpRecordCreate, k, 0, in, fmt.Sprintf("add %s %s %s", n.Name, n.Type, n.Data))
} else if !sameObject(o, n) {
in.ZoneID = 0
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
}
}
for _, k := range sortedKeys(to.Blocklists) {
n := to.Blocklists[k]
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
if o, ok := from.Blocklists[k]; !ok {
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
} else if !sameObject(o, n) {
add(OpBlocklistUpdate, k, o.Version, in, "restore blocklist "+n.Name)
}
}
for _, k := range sortedKeys(to.BlockRules) {
n := to.BlockRules[k]
in := BlockRuleInput{Name: &n.Name, Action: &n.Action, Exact: &n.Exact, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.BlockRules[k]; !ok {
add(OpBlockRuleCreate, k, 0, in, "add rule: "+ruleLabel(n))
} else if !sameObject(o, n) {
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.BlockRules) {
if _, ok := to.BlockRules[k]; !ok {
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
}
}
for _, k := range sortedKeys(from.Blocklists) {
if _, ok := to.Blocklists[k]; !ok {
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
}
}
for _, k := range sortedKeys(from.Records) {
if _, ok := to.Records[k]; !ok {
o := from.Records[k]
// Records of zones that are deleted go with the zone.
if _, zoneStays := to.Zones[o.ZoneID]; zoneStays {
add(OpRecordDelete, k, o.Version, nil, fmt.Sprintf("delete %s %s %s", o.Name, o.Type, o.Data))
}
}
}
for _, k := range sortedKeys(from.Options) {
if _, ok := to.Options[k]; !ok {
o := from.Options[k]
stays := true
switch o.Scope {
case model.ScopeZone:
_, stays = to.Zones[o.ScopeID]
case model.ScopeView:
_, stays = to.Views[o.ScopeID]
}
if stays {
add(OpOptionDelete, k, o.Version, nil, "delete "+optionLabel(from, o))
}
}
}
for _, k := range sortedKeys(from.Zones) {
if _, ok := to.Zones[k]; !ok {
o := from.Zones[k]
add(OpZoneDelete, k, o.Version, nil, "delete zone "+dnsname.Display(o.Name))
}
}
for _, k := range sortedKeys(from.Views) {
if _, ok := to.Views[k]; !ok {
o := from.Views[k]
add(OpViewDelete, k, o.Version, DeleteInput{Cascade: true}, "delete view "+o.Name)
}
}
for _, k := range sortedKeys(from.ACLs) {
if _, ok := to.ACLs[k]; !ok {
add(OpACLDelete, k, from.ACLs[k].Version, nil, "delete ACL "+from.ACLs[k].Name)
}
}
for _, k := range sortedKeys(from.Keys) {
if _, ok := to.Keys[k]; !ok {
add(OpTSIGDelete, k, from.Keys[k].Version, nil, "delete TSIG key "+from.Keys[k].Name)
}
}
for i := range ops {
ops[i].Seq = i + 1
}
return ops
}
func stripVersion(s model.Settings) model.Settings {
s.Version = 0
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
return s
}
func sortedKeys[T any](m map[int64]*T) []int64 {
keys := make([]int64, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
return keys
}
// sameObject compares two objects ignoring their meta data.
func sameObject[T any](a, b *T) bool {
ca, cb := *a, *b
clearMeta(&ca)
clearMeta(&cb)
return reflect.DeepEqual(ca, cb)
}
func clearMeta(v any) {
rv := reflect.ValueOf(v).Elem()
if f := rv.FieldByName("Meta"); f.IsValid() {
f.Set(reflect.Zero(f.Type()))
}
}