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>
128 lines
3.4 KiB
Go
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)
|
|
}
|