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:
adamandClaude Opus 5.5 committed 2026-10-12 15:53:58 +01:00
1 parent 6313fa51f8
commit 057433c198
31 files changed
+1729 -55

No files matched your search

+157 -9
View File
@@ -131,7 +131,24 @@ func dateSerial(now time.Time) uint32 {
}
func (x *expander) expand(req Request) ([]model.Op, error) {
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link.
return x.linkSync(ops)
}
return ops, nil
}
func (x *expander) expandOne(req Request) ([]model.Op, error) {
switch req.Op {
case OpZoneLink:
return x.zoneLink(req)
case OpZoneUnlink:
return x.zoneUnlink(req)
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneUpdate, OpZoneDelete:
@@ -152,7 +169,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
}
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
if req.Op == OpZoneDelete {
if rest := x.st.LinkedWith(z); len(rest) == 1 {
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
}
} else {
var in ZoneInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
}
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
}
return ops, nil
case OpRecordCreate:
return x.recordCreate(req)
case OpRecordUpdate:
@@ -355,7 +389,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
records := in.Records
cloneFrom := in.CloneFrom
zoneOpts := in.Options
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
linkWith := in.LinkWith
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
if linkWith != "" {
if cloneFrom != 0 {
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
}
v := x.st.ViewByName(linkWith)
if v == nil {
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
}
o := x.st.FindZone(name, v.ID)
if o == nil {
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
}
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "only primary zones can be linked")
}
cloneFrom = o.ID
}
set := x.st.Settings
var src *model.Zone
@@ -480,9 +532,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, err
}
}
var linkOps []model.Op
if src != nil && linkWith != "" {
// The new zone joins src's link, or starts one with src: then
// all of src's records become shared.
group := src.LinkGroup
if group == 0 {
group = src.ID
linkOps = x.shareAllOps(src, group)
}
in.LinkGroup = &group
}
if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) {
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
continue // local to src's view
}
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
if linkWith != "" {
id := r.SharedID
if id == 0 {
id = r.ID
}
ri.SharedID = &id
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
@@ -512,10 +585,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
summary += " in view " + v.Name
}
}
if src != nil {
if src != nil && linkWith != "" {
summary += " (linked with " + zoneDesc(x.st, src) + ")"
} else if src != nil {
summary += " (copy of " + zoneDesc(x.st, src) + ")"
}
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
for i, zo := range zoneOpts {
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
if zo.Comment != "" {
@@ -666,7 +741,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
if err != nil {
return nil, err
}
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
in.SharedID = nil
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, p.Type, in.Local)
if err != nil {
return nil, err
}
if local {
in.SharedID = model.Ptr(int64(0))
summary += " (local to view " + x.st.ViewName(z) + ")"
} else {
if x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
in.SharedID = &ids[0]
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if err != nil {
@@ -690,6 +785,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
ptr := in.PTR
in.PTR = false
in.Zone, in.View = "", ""
if in.SharedID != nil {
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
}
var pre []model.Op
if in.Local != nil {
if z.LinkGroup == 0 {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
if want := *in.Local; want != (r.SharedID == 0) {
// The whole set of this name and type changes sides.
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
id := int64(0)
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
if !want {
id = o.ID
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
}
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
}
if !want {
// Sharing a version makes it every view's.
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
}
}
in.Local = nil
if string(mustJSON(in)) == "{}" {
return pre, nil
}
}
name, typ, data := r.Name, r.Type, r.Data
if in.Name != nil {
name = *in.Name
@@ -751,11 +875,17 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
case len(changes) > 0:
summary += ": " + strings.Join(changes, ", ")
}
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
}
base := req.BaseVersion
if base == 0 {
base = r.Version
}
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
@@ -953,17 +1083,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
canon = append(canon, p.Data)
}
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
where := ""
var pre []model.Op
if z.LinkGroup != 0 {
local, err := x.localFor(z, owner, typ, in.Local)
if err != nil {
return nil, err
}
in.Shared = model.Ptr(!local)
if local {
where = " (local to view " + x.st.ViewName(z) + ")"
} else if in.Local != nil {
// Explicitly shared: this version becomes every view's.
pre = x.dropOverrides(z, owner, typ)
}
} else if in.Local != nil && *in.Local {
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
}
in.Local = nil
if len(canon) > 0 {
in.NewIDs, err = x.ids(len(canon))
if err != nil {
return nil, err
}
}
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {