Files
jarl/internal/validate/linked.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

147 lines
3.9 KiB
Go

package validate
import (
"fmt"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
// checkLinked checks linked zones (docs/views.md#linked-zones): what can
// be linked, and that their shared records are in step. Jarl keeps them
// in step itself; they can only drift when a change was staged before the
// zones were linked (or by editing the database).
func (c *checker) checkLinked() {
groups := map[int64][]*model.Zone{}
for _, z := range c.s.SortedZones() {
if z.LinkGroup != 0 {
groups[z.LinkGroup] = append(groups[z.LinkGroup], z)
}
}
for _, g := range sortedGroupIDs(groups) {
zs := c.s.LinkMembers(g)
label := func(z *model.Zone) string {
if v := c.s.ViewName(z); v != "" {
return dnsname.Display(z.Name) + " [" + v + "]"
}
return dnsname.Display(z.Name)
}
bad := false
for _, z := range zs {
switch {
case z.Type != model.ZonePrimary:
c.errf("linked_zone_type", "zone", z.ID, "zone %s is linked, but only primary zones can be: unlink it", label(z))
bad = true
case z.Name != zs[0].Name:
c.errf("linked_zone_name", "zone", z.ID, "zone %s is linked with %s; only zones of the same name can be linked", label(z), label(zs[0]))
bad = true
}
}
if len(zs) == 1 {
c.infof("linked_zone_alone", "zone", zs[0].ID, "zone %s is linked with no other zone; unlink it (jarl zone unlink)", label(zs[0]))
continue
}
if bad {
continue
}
// Shared and local records of one name and type can't mix in a zone.
type key struct{ name, typ string }
keys := map[key]bool{}
var order []key
for _, z := range zs {
for _, r := range c.s.ZoneRecords(z.ID) {
k := key{r.Name, r.Type}
if !keys[k] {
keys[k] = true
order = append(order, k)
}
}
}
for _, k := range order {
var truth []*model.Record
var truthZone *model.Zone
for _, z := range zs {
rs := c.s.RRset(z.ID, k.name, k.typ)
local, shared := 0, 0
for _, r := range rs {
if r.SharedID == 0 {
local++
} else {
shared++
}
}
if local > 0 && shared > 0 {
c.errf("linked_mixed", "zone", z.ID, "%s %s in zone %s has both shared and local records; make the set one or the other", dnsname.Display(k.name), k.typ, label(z)).Zone = z.Name
continue
}
if local > 0 {
continue // an override
}
got := c.s.SharedRecords(z.ID, k.name, k.typ)
if truthZone == nil {
truth, truthZone = got, z
continue
}
if diff := sharedDiff(truth, got); diff != "" {
p := c.errf("linked_out_of_sync", "zone", z.ID, "linked zones %s and %s differ at %s %s (%s). Jarl keeps them in step, so a change was probably staged before they were linked: link them again (jarl zone link) to work out what is shared", label(truthZone), label(z), dnsname.Display(k.name), k.typ, diff)
p.Zone = z.Name
}
}
}
}
}
func sortedGroupIDs(m map[int64][]*model.Zone) []int64 {
var out []int64
for k := range m {
out = append(out, k)
}
for i := range out {
for j := i + 1; j < len(out); j++ {
if out[j] < out[i] {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
// sharedDiff says how two zones' copies of a shared set differ ("" when
// they don't).
func sharedDiff(a, b []*model.Record) string {
idx := map[int64]*model.Record{}
for _, r := range b {
idx[r.SharedID] = r
}
var out []string
for _, r := range a {
o := idx[r.SharedID]
delete(idx, r.SharedID)
switch {
case o == nil:
out = append(out, "missing "+r.Data)
case o.Data != r.Data:
out = append(out, r.Data+" vs "+o.Data)
case !sameTTLPtr(o.TTL, r.TTL):
out = append(out, "TTL of "+r.Data)
case o.Disabled != r.Disabled:
out = append(out, "disabled "+r.Data)
}
}
for _, r := range idx {
out = append(out, "extra "+r.Data)
}
if len(out) > 3 {
out = append(out[:3], fmt.Sprintf("and %d more", len(out)-3))
}
return strings.Join(out, ", ")
}
func sameTTLPtr(a, b *uint32) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}