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>
This commit is contained in:
1 parent
6313fa51f8
commit
057433c198
31 files changed
+1729
-55
No files matched your search
@@ -0,0 +1,476 @@
|
||||
package engine
|
||||
|
||||
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
||||
// different views can be linked: their shared records are kept in step,
|
||||
// and each zone can override a name and type with records local to its
|
||||
// view. Every zone keeps its own copy of each shared record (with the
|
||||
// same SharedID), so the rest of Jarl sees ordinary zones.
|
||||
//
|
||||
// Keeping them in step happens when requests are expanded: the request's
|
||||
// operations are applied to a scratch copy, and linkSync adds explicit
|
||||
// operations for the other zones of the link. So a change to a shared
|
||||
// record is one changeset with one operation per zone: the diff, the
|
||||
// permission checks, history and undo all see every zone.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
type rrKey struct {
|
||||
zone int64
|
||||
name, typ string
|
||||
}
|
||||
|
||||
// linkSync returns ops followed by the operations that bring the other
|
||||
// zones of each affected link in step.
|
||||
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
||||
if !x.st.AnyLinked() {
|
||||
return ops, nil
|
||||
}
|
||||
work := x.st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range ops {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys := affectedKeys(x.st, work, ops)
|
||||
var extra []model.Op
|
||||
for _, k := range keys {
|
||||
z := work.Zones[k.zone]
|
||||
if z == nil || z.LinkGroup == 0 {
|
||||
continue
|
||||
}
|
||||
more, err := x.syncKey(work, z, k.name, k.typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range more {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
extra = append(extra, more...)
|
||||
}
|
||||
return append(ops, extra...), nil
|
||||
}
|
||||
|
||||
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
||||
// and after.
|
||||
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
||||
var out []rrKey
|
||||
seen := map[rrKey]bool{}
|
||||
add := func(zone int64, name, typ string) {
|
||||
k := rrKey{zone, name, typ}
|
||||
if zone != 0 && name != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
fromRecord := func(id int64) {
|
||||
for _, st := range []*model.State{pre, post} {
|
||||
if r := st.Records[id]; r != nil {
|
||||
add(r.ZoneID, r.Name, r.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
||||
if op.ID != 0 {
|
||||
fromRecord(op.ID)
|
||||
}
|
||||
if op.Kind == OpRecordDelete && op.ID == 0 {
|
||||
var in RecordInput
|
||||
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
||||
add(in.ZoneID, *in.Name, *in.Type)
|
||||
}
|
||||
}
|
||||
case OpRRSetReplace:
|
||||
var in RRSetInput
|
||||
if decode(op.Data, &in) == nil {
|
||||
add(in.ZoneID, in.Name, in.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncKey brings the other zones of z's link in step with z for one name
|
||||
// and type, after z was changed:
|
||||
//
|
||||
// - z overrides it (local records): z's shared copies go; the others
|
||||
// keep theirs.
|
||||
// - z's override was just removed: z gets the shared records back from
|
||||
// a zone that has them.
|
||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||
// override the name and type gets the same (created, updated or
|
||||
// deleted to match).
|
||||
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
||||
var ops []model.Op
|
||||
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
||||
if st.HasLocal(z.ID, name, typ) {
|
||||
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
||||
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
truth := st.SharedRecords(z.ID, name, typ)
|
||||
members := st.LinkedWith(z)
|
||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||
// The override was removed: back to the shared records.
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue
|
||||
}
|
||||
src := st.SharedRecords(o.ID, name, typ)
|
||||
if len(src) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, r := range src {
|
||||
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
break
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue // o overrides this name and type
|
||||
}
|
||||
copies := map[int64]*model.Record{}
|
||||
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||
copies[r.SharedID] = r
|
||||
}
|
||||
var more []model.Op
|
||||
for _, t := range truth {
|
||||
cp := copies[t.SharedID]
|
||||
delete(copies, t.SharedID)
|
||||
if cp == nil {
|
||||
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
more = append(more, op)
|
||||
continue
|
||||
}
|
||||
in, changes := recordDiff(cp, t)
|
||||
if len(changes) > 0 {
|
||||
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
||||
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(copies) {
|
||||
cp := copies[id]
|
||||
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
||||
}
|
||||
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
||||
dnsname.Display(name), typ, where(o), where(z))
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// copyRecord returns an operation that creates a copy of shared record r
|
||||
// in zone z.
|
||||
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
||||
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
||||
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return model.Op{}, err
|
||||
}
|
||||
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
||||
}
|
||||
|
||||
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
||||
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
||||
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
||||
if r.TTL != nil {
|
||||
in.TTL = model.Some(*r.TTL)
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// recordDiff returns the update that makes cp like t, and what changes.
|
||||
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
||||
var in RecordInput
|
||||
var changes []string
|
||||
if cp.Data != t.Data {
|
||||
in.Data = model.Ptr(t.Data)
|
||||
changes = append(changes, "data "+t.Data)
|
||||
}
|
||||
if !sameTTL(cp.TTL, t.TTL) {
|
||||
if t.TTL != nil {
|
||||
in.TTL = model.Some(*t.TTL)
|
||||
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
changes = append(changes, "ttl default")
|
||||
}
|
||||
}
|
||||
if cp.Comment != t.Comment {
|
||||
in.Comment = model.Ptr(t.Comment)
|
||||
changes = append(changes, "comment")
|
||||
}
|
||||
if cp.Disabled != t.Disabled {
|
||||
in.Disabled = model.Ptr(t.Disabled)
|
||||
changes = append(changes, enableWord(t.Disabled)+"d")
|
||||
}
|
||||
return in, changes
|
||||
}
|
||||
|
||||
// linkTarget resolves the zone a link request names.
|
||||
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// zoneLink links zones of the same name in several views. It never
|
||||
// changes what named answers: a name and type with the same records in
|
||||
// every zone becomes shared; anything that differs stays local to each
|
||||
// view (and can be shared later, from the view whose version is right).
|
||||
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.linkTarget(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := []*model.Zone{z}
|
||||
add := func(o *model.Zone) {
|
||||
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
||||
members = append(members, o)
|
||||
}
|
||||
}
|
||||
if len(in.With) == 0 {
|
||||
for _, o := range x.st.ZonesNamed(z.Name) {
|
||||
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, vn := range in.With {
|
||||
v := x.st.ViewByName(vn)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
||||
}
|
||||
o := x.st.FindZone(z.Name, v.ID)
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
||||
}
|
||||
if o.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
||||
}
|
||||
add(o)
|
||||
}
|
||||
// Zones already linked with any of them join too.
|
||||
for _, m := range slices.Clone(members) {
|
||||
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
if len(members) < 2 {
|
||||
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
||||
}
|
||||
x.st.SortByView(members)
|
||||
group := members[0].ID
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != 0 {
|
||||
group = m.LinkGroup
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// What is the same everywhere?
|
||||
type key struct{ name, typ string }
|
||||
sig := func(rs []*model.Record) string {
|
||||
var parts []string
|
||||
for _, r := range rs {
|
||||
ttl := "-"
|
||||
if r.TTL != nil {
|
||||
ttl = fmt.Sprint(*r.TTL)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
keys := map[key]bool{}
|
||||
var order []key
|
||||
for _, m := range members {
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
if !keys[k] {
|
||||
keys[k] = true
|
||||
order = append(order, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ops []model.Op
|
||||
var local []string
|
||||
shared := 0
|
||||
for _, k := range order {
|
||||
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
||||
same := len(first) > 0
|
||||
for _, m := range members[1:] {
|
||||
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
shared++
|
||||
} else {
|
||||
var in []string
|
||||
for _, m := range members {
|
||||
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
||||
in = append(in, x.st.ViewName(m))
|
||||
}
|
||||
}
|
||||
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
||||
}
|
||||
// Pair the copies: record i of the first zone's set (by data) with
|
||||
// the same data in the others.
|
||||
byData := func(rs []*model.Record) []*model.Record {
|
||||
out := slices.Clone(rs)
|
||||
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
return out
|
||||
}
|
||||
ref := byData(first)
|
||||
for _, m := range members {
|
||||
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
||||
for i, r := range rs {
|
||||
want := int64(0)
|
||||
var comment *string
|
||||
if same {
|
||||
want = ref[i].ID
|
||||
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
||||
comment = model.Ptr(ref[i].Comment)
|
||||
}
|
||||
}
|
||||
if r.SharedID == want && comment == nil {
|
||||
continue
|
||||
}
|
||||
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
||||
what := "local to view " + x.st.ViewName(m)
|
||||
if same {
|
||||
what = "shared"
|
||||
}
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
||||
}
|
||||
}
|
||||
}
|
||||
var views []string
|
||||
for _, m := range members {
|
||||
views = append(views, x.st.ViewName(m))
|
||||
}
|
||||
var zoneOps []model.Op
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != group {
|
||||
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
||||
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
||||
}
|
||||
}
|
||||
if len(local) > 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
||||
return append(zoneOps, ops...), nil
|
||||
}
|
||||
|
||||
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
||||
// all local. A link left with one zone is dissolved.
|
||||
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
||||
}
|
||||
out := []*model.Zone{z}
|
||||
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||
out = append(out, rest[0])
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, m := range out {
|
||||
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// unlinkOps returns the operations that take zone m out of its link.
|
||||
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
if r.SharedID != 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// localFor works out whether a new record (or set) in zone z is local:
|
||||
// as asked, or like the records of the same name and type already there.
|
||||
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||
has := x.st.HasLocal(z.ID, name, typ)
|
||||
if want == nil {
|
||||
return has, nil
|
||||
}
|
||||
return *want, nil
|
||||
}
|
||||
|
||||
// shareAllOps returns the operations that start a link with zone z: it
|
||||
// joins group and all its records become shared.
|
||||
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
||||
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||
if r.SharedID == 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// dropOverrides returns the operations that delete the other linked
|
||||
// zones' local records of a name and type, so the shared version reaches
|
||||
// them (sharing a version makes it every view's).
|
||||
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||
var ops []model.Op
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||
if r.SharedID == 0 {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
Reference in new issue
Block a user