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
+157
-9
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user