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) { add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil]) } 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} 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 = nil // its 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())) } }