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

+32 -2
View File
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.Comment != nil {
z.Comment = *in.Comment
}
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
return nil
}
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil {
r.Disabled = *in.Disabled
}
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
for _, w := range p.Warnings {
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
}
@@ -526,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil {
r.Disabled = *in.Disabled
}
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
r.Touch(c.user(), c.now)
return nil
}
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
k := strings.ToLower(r.Data)
if seen[k] && !keep[k] {
keep[k] = true
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
shared := r.SharedID
if in.Shared != nil {
shared = 0
if *in.Shared {
shared = r.SharedID
if shared == 0 {
shared = r.ID
}
}
}
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
if ttlKnown {
r.TTL = copyTTL(ttl)
}
r.Disabled = false
r.Disabled, r.SharedID = false, shared
r.Touch(c.user(), c.now)
}
continue
}
if r.Disabled {
// Kept, but shared or local like the rest of the set.
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
r.SharedID = 0
if *in.Shared {
r.SharedID = r.ID
}
r.Touch(c.user(), c.now)
}
continue
}
delete(st.Records, id)
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
if in.Comment != nil {
r.Comment = *in.Comment
}
if in.Shared != nil && *in.Shared {
r.SharedID = r.ID
}
r.Touch(c.user(), c.now)
st.Records[r.ID] = r
}