Files
jarl/internal/engine/diffops.go
T
adamandClaude Opus 5.5 057433c198 Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:58 +01:00

249 lines
9.6 KiB
Go

package engine
import (
"fmt"
"reflect"
"slices"
"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,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "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),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
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, LinkGroup: model.Ptr(n.LinkGroup)}
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, SharedID: model.Ptr(n.SharedID)}
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))
}
}
// Steps before the lists and groups that use them.
for _, k := range sortedKeys(to.BlockSteps) {
n := to.BlockSteps[k]
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
if o, ok := from.BlockSteps[k]; !ok {
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
} else if !sameObject(o, n) {
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
}
}
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, StepID: model.Ptr(n.StepID)}
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))
}
}
for _, k := range sortedKeys(to.ClientGroups) {
n := to.ClientGroups[k]
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.ClientGroups[k]; !ok {
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
} else if !sameObject(o, n) {
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.ClientGroups) {
if _, ok := to.ClientGroups[k]; !ok {
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
}
}
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.BlockSteps) {
if _, ok := to.BlockSteps[k]; !ok {
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[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()))
}
}