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,146 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user