Files
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

128 lines
3.4 KiB
Go

package main
import (
"fmt"
"os"
"strings"
"jarl/internal/api"
"jarl/internal/client"
)
// zoneViews expands a --view value for a record command: "" or one view
// as it is, "a,b" to those views, and "all" to every view that has the
// zone.
// localFanOut is set by commands making local changes (--local), which
// go to every view named even when the zones are linked.
var localFanOut bool
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
if view != "all" && !strings.Contains(view, ",") {
return []string{view}, nil
}
var zones []api.Zone
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
return nil, err
}
want := strings.ToLower(strings.TrimSuffix(zone, "."))
link := map[string]int64{}
var out []string
for _, z := range zones {
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
link[z.View] = z.LinkGroup
if view == "all" {
out = append(out, z.View)
}
}
}
if view != "all" {
for _, v := range strings.Split(view, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("zone %s is in no view", zone)
}
// Linked zones (docs/views.md#linked-zones) get a shared change once:
// Jarl copies it to the others. A local change goes to each.
if localFanOut {
return out, nil
}
var dedup []string
first := map[int64]string{}
for _, v := range out {
g := link[v]
if g != 0 {
if f, ok := first[g]; ok {
if !flagQuiet {
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
}
continue
}
first[g] = v
}
dedup = append(dedup, v)
}
return dedup, nil
}
// perView runs a record command once per view. With several views, the
// changes are staged into one changeset and applied together, so either
// every view gets the change or none does (with --changeset, they are
// staged there).
//
// do returns the server's response, or nil when there was nothing to do.
func perView(c *client.Client, views []string, description string, do func(c *client.Client, view string) (*api.MutationResponse, error)) error {
if len(views) == 1 || c.Changeset > 0 {
for _, v := range views {
resp, err := do(c, v)
if err != nil {
if len(views) > 1 {
err = fmt.Errorf("view %s: %w", v, err)
}
return err
}
if resp != nil {
if err := report(resp); err != nil {
return err
}
}
}
return nil
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": description + " (views " + strings.Join(views, ", ") + ")"}, &cs); err != nil {
return err
}
discard := func() { c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/discard", cs.ID), nil, nil, nil) }
staging := *c
staging.Changeset, staging.DryRun = cs.ID, false
staged := 0
for _, v := range views {
resp, err := do(&staging, v)
if err != nil {
discard()
fmt.Fprintf(os.Stderr, "view %s failed, so nothing was changed in any view\n", v)
return err
}
if resp != nil {
staged++
}
}
if staged == 0 {
discard()
return nil
}
var resp api.MutationResponse
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
if err != nil || c.DryRun {
discard()
}
if err != nil {
return err
}
return report(&resp)
}