diff --git a/CHANGELOG.md b/CHANGELOG.md index d0436a8..7ecbc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,19 @@ All notable changes to Jarl are documented here. The format follows delegation and its glue stay. - Zone commands take a network for a reverse zone: `jarl zone show 10.0.10.0/24`. +- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`, + and a **Compare** button on each record. + +### Changed + +- **Compare views** and `jarl zone diff` are rebuilt around the view + you're on (docs/views.md#comparing-views): a summary per view, then + what is answered differently (a view's own records in linked zones, + drift otherwise) with **Use this everywhere**, the names only some + views have, grouped, with **Add to VIEW**, and how many are the same. + One line per record: the CLI cuts long TXT records to the terminal + (`--wide` shows them whole). `--with` picks the views to compare with. + `GET /zones/{zone}/compare` returns these sections instead of `rows`. ### Fixed diff --git a/cmd/jarl/compare_test.go b/cmd/jarl/compare_test.go new file mode 100644 index 0000000..5be330b --- /dev/null +++ b/cmd/jarl/compare_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "strings" + "testing" + "unicode/utf8" +) + +func TestPrintCompare(t *testing.T) { + long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"` + r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true} + r.Summary = append(r.Summary, struct { + View string `json:"view"` + Different int `json:"different"` + OnlyBase int `json:"only_base"` + OnlyOther int `json:"only_other"` + Same int `json:"same"` + }{View: "external", Different: 1, OnlyBase: 1, Same: 5}) + r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{ + {View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"}, + {View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"}, + }}} + r.Only = append(r.Only, struct { + Views []string `json:"views"` + Rows []compareRow `json:"rows"` + }{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}}) + r.Same = 5 + + var b bytes.Buffer + printCompare(&b, r, false, 60) + out := b.String() + // Record lines fit (the heading and summary are prose, and may wrap). + for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] { + if n := utf8.RuneCountInString(l); n > 60 { + t.Errorf("line of %d characters: %q", n, l) + } + } + for _, want := range []string{ + "adbraun.lol in internal, compared with external\n linked: ", + " external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same", + "ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:", + "…\n \"short\"\n external \"other\" [local, id 9]", + "ONLY IN INTERNAL (1)\n nas A 10.0.10.20", + "5 names and types are the same (--all lists them)", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + // Width 0: whole. + b.Reset() + printCompare(&b, r, false, 0) + if !strings.Contains(b.String(), long+" [local, id 7]") { + t.Errorf("whole: %s", b.String()) + } +} diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index cff58ba..a173096 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -962,89 +962,208 @@ zone is dissolved.` // zoneDiffCmd is 'zone diff': a zone's records side by side in the views // that have it. +type compareSet struct { + View string `json:"view"` + Records []string `json:"records"` + IDs []int64 `json:"ids"` + Linked string `json:"linked"` +} + +type compareRow struct { + Name string `json:"name"` + Type string `json:"type"` + Views []compareSet `json:"views"` +} + +type compareReport struct { + Zone string `json:"zone"` + Base string `json:"base"` + Views []string `json:"views"` + Linked bool `json:"linked"` + Summary []struct { + View string `json:"view"` + Different int `json:"different"` + OnlyBase int `json:"only_base"` + OnlyOther int `json:"only_other"` + Same int `json:"same"` + } `json:"summary"` + Different []compareRow `json:"different"` + Only []struct { + Views []string `json:"views"` + Rows []compareRow `json:"rows"` + } `json:"only"` + Same int `json:"same"` + SameRows []compareRow `json:"same_rows"` +} + func zoneDiffCmd() *cobra.Command { - var views []string - var all bool + var view string + var with []string + var all, wide bool cmd := &cobra.Command{ - Use: "diff ZONE [--view V --view W] [--all]", - Short: "Show what differs between the views that have a zone", - Long: `Lines up a zone's records in the views that have it (or the views -given), one row per name and type, and shows the ones that differ: a -local override in a linked zone, a record one view lacks. With --all, the -ones that are the same too. In a linked zone, 'jarl record update ID ---shared' makes a view's version every view's.`, + Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]", + Short: "Compare a zone with the same zone in other views", + Long: `Compares a zone in one view (--view, default the first view that has it) +with the same zone in the other views (--with, default all of them): how +many names each answers differently, the names and types only some views +have, and how many are the same. NAME (and TYPE) narrows it to one name, +the same records included. One line per record; long values are cut to +fit the terminal (--wide shows them whole). + +In a linked zone a difference is a view's own (local) records, on +purpose; between zones that aren't linked it is drift. 'jarl record +update ID --shared' makes a linked view's version every view's.`, Example: ` jarl zone diff adbraun.lol - jarl zone diff adbraun.lol --view internal --view external --all`, - Args: cobra.ExactArgs(1), + jarl zone diff adbraun.lol --view internal --with external + jarl zone diff adbraun.lol nas + jarl zone diff adbraun.lol @ TXT --wide`, + Args: cobra.RangeArgs(1, 3), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } - path, qv := zonePath(args[0], "") - if len(views) > 0 { - qv.Set("views", strings.Join(views, ",")) + path, qv := zonePath(args[0], view) + if len(with) > 0 { + qv.Set("with", strings.Join(with, ",")) + } + if len(args) > 1 { + qv.Set("name", args[1]) + } + if len(args) > 2 { + qv.Set("type", args[2]) } if all { qv.Set("all", "1") } - var r struct { - Zone string `json:"zone"` - Views []string `json:"views"` - Linked bool `json:"linked"` - Same int `json:"same"` - Rows []struct { - Name string `json:"name"` - Type string `json:"type"` - Same bool `json:"same"` - Views []struct { - Records []string `json:"records"` - IDs []int64 `json:"ids"` - Linked string `json:"linked"` - } `json:"views"` - } `json:"rows"` - } + var r compareReport if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil { return err } - return output(r, func(w io.Writer) { - head := []string{"NAME", "TYPE"} - for _, v := range r.Views { - head = append(head, strings.ToUpper(v)) - } - fmt.Fprintln(w, strings.Join(head, "\t")) - for _, row := range r.Rows { - cols := []string{row.Name, row.Type} - for _, vs := range row.Views { - cell := "-" - if len(vs.Records) > 0 { - cell = strings.Join(vs.Records, ", ") - if vs.Linked == "local" { - cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]" - } - } - cols = append(cols, cell) - } - fmt.Fprintln(w, strings.Join(cols, "\t")) - } - link := "not linked (jarl zone link " + r.Zone + ")" - if r.Linked { - link = "linked" - } - fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link) - }) + width := 0 + if !wide { + width = terminalWidth() + } + return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) }) }, } - cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)") - cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere") + cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)") + cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)") + cmd.Flags().BoolVar(&all, "all", false, "also list what is the same") + cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal") return cmd } -func boolCount(b bool, n int) int { - if b { - return n +// printCompare prints a comparison as sections, one line per record, +// each cut to width characters (0: whole). +func printCompare(w io.Writer, r *compareReport, oneName bool, width int) { + vw := 0 // the views column + for _, v := range r.Views { + vw = max(vw, len(v)) } - return 0 + line := func(indent, label, text string) { + s := indent + label + text + if width > 0 && utf8.RuneCountInString(s) > width { + keep := max(width-utf8.RuneCountInString(indent+label)-1, 8) + text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…" + } + fmt.Fprintln(w, indent+label+text) + } + link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)" + if r.Linked { + link = "linked: a difference is a view's own records, there on purpose" + } + fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link) + if !oneName { + for _, s := range r.Summary { + fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same) + } + } + name := func(row compareRow) string { return row.Name + " " + row.Type } + sets := func(row compareRow, local bool) { + for _, vs := range row.Views { + label := fmt.Sprintf(" %-*s ", vw, vs.View) + if len(vs.Records) == 0 { + line("", label, "-") + continue + } + for i, d := range vs.Records { + if i > 0 { + label = strings.Repeat(" ", 4+vw+2) + } + suffix := "" + if local && vs.Linked == "local" && i == 0 { + suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]" + } + line("", label, d+suffix) + } + } + } + if len(r.Different) > 0 { + fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different)) + for _, row := range r.Different { + fmt.Fprintln(w, " "+name(row)) + sets(row, true) + } + } + for _, o := range r.Only { + fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows)) + nw := 0 + for _, row := range o.Rows { + nw = max(nw, len(name(row))) + } + for _, row := range o.Rows { + var recs []string + for _, vs := range row.Views { + if len(vs.Records) > 0 { + recs = vs.Records + break + } + } + for i, d := range recs { + label := fmt.Sprintf(" %-*s ", nw, name(row)) + if i > 0 { + label = strings.Repeat(" ", 2+nw+2) + } + line("", label, d) + } + } + } + if len(r.SameRows) > 0 { + fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows)) + nw := 0 + for _, row := range r.SameRows { + nw = max(nw, len(name(row))) + } + for _, row := range r.SameRows { + for i, d := range row.Views[0].Records { + label := fmt.Sprintf(" %-*s ", nw, name(row)) + if i > 0 { + label = strings.Repeat(" ", 2+nw+2) + } + line("", label, d) + } + } + } else if r.Same > 0 && !oneName { + fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same) + } + if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 { + fmt.Fprintln(w, "\nNo records of that name in these views.") + } +} + +// terminalWidth returns the terminal's width, or 0 when standard output +// isn't a terminal (then nothing is cut). +func terminalWidth() int { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + return 0 + } + width, _, err := term.GetSize(fd) + if err != nil || width <= 0 { + return 0 + } + return width } func zoneFillPTRCmd() *cobra.Command { diff --git a/docs/api.md b/docs/api.md index 96d675b..6d0a44d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -363,7 +363,7 @@ and `linked_with` (the other views). | POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) | | GET | `/reverse-zones/suggestions` | The reverse zones the private views lack: `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` | | POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}`: don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions | -| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view | +| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it | `POST /zones` takes `"link_with": "VIEW"` to create a zone linked with that view's zone of the same name. diff --git a/docs/cli.md b/docs/cli.md index 1b35372..14b3264 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -227,7 +227,8 @@ jarl zone unlink adbraun.lol --view vps jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here jarl record update 1234 --local | --shared -jarl zone diff adbraun.lol [--view V --view W] [--all] # what differs between the views +jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views +jarl zone diff adbraun.lol nas [A] # just one name ``` When a zone exists in several views, add `--view NAME` to commands that diff --git a/docs/views.md b/docs/views.md index 0a1c600..83a6e68 100644 --- a/docs/views.md +++ b/docs/views.md @@ -209,20 +209,6 @@ each view (Jarl lists them). Share the right version afterwards. Unlinking keeps every record, all local; a link left with one zone is dissolved, as when the other zone is deleted. -**Comparing the views.** The zone's **Compare views** tab, or `jarl zone -diff adbraun.lol`, lines the views up and shows each name and type that -differs: a view's own records, or a name one view lacks. **Use this -version** (`jarl record update ID --shared`) makes one view's version -every view's. It works for zones that aren't linked too, to see what -linking them would keep local. - -``` -$ jarl zone diff adbraun.lol -NAME TYPE INTERNAL EXTERNAL -nas A 10.0.10.20 [local, id 812] 203.0.113.20 [local, id 790] -5 names and types differ, 21 are the same; linked -``` - `--local` also works on a zone that isn't linked (its records are local anyway), so scripts don't need to know; `--shared` there is refused. @@ -246,6 +232,54 @@ as it is: linked zones can differ per view, keep their own options, and don't depend on the view order (`in-view` can only use a view defined before it). +## Comparing views + +A zone's **Compare views** tab, or `jarl zone diff`, compares it with the +same zone in another view (or all of them), from the view you're on: + +``` +$ jarl zone diff adbraun.lol --view internal +adbraun.lol in internal, compared with external + linked: a difference is a view's own records, there on purpose + external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same + +ANSWERED DIFFERENTLY (1) + nas A + internal 10.0.10.20 [local, id 812] + external 203.0.113.20 [local, id 790] + +ONLY IN INTERNAL (24) + printer A 10.0.10.30 + router A 10.0.10.1 + ... + +ONLY IN EXTERNAL (2) + _acme-challenge TXT "…" + ... + +31 names and types are the same (--all lists them) +``` + +* **Answered differently**: names and types more than one view has, with + different records. In linked zones that is a view's own (local) + records, there on purpose; between zones that aren't linked, it is a + difference that crept in. **Use this everywhere** gives every view one + view's records (in linked zones by sharing them: `jarl record update ID + --shared`). +* **Only in …**: names and types some views lack. **Add to external** + gives that view the same records, its own. +* **The same**: how many; `--all` (or "List them") shows them. + +One line per record: the CLI cuts long values to fit the terminal +(`--wide` shows them whole); the web UI shows the whole value on hover. +The apex NS records are left out, since views often list their name +servers differently on purpose. + +**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`) +shows just that name in every view, the same records included. In the +web UI, a record's **Compare** button does the same. `--with external` +(repeat it) picks the views to compare with. + ## Living with views * Commands that name a zone that exists in several views need `--view` diff --git a/internal/engine/compare.go b/internal/engine/compare.go index 8159113..8882426 100644 --- a/internal/engine/compare.go +++ b/internal/engine/compare.go @@ -5,6 +5,7 @@ import ( "slices" "strings" + "jarl/internal/dnsname" "jarl/internal/model" ) @@ -14,6 +15,8 @@ type ViewSet struct { ZoneID int64 `json:"zone_id"` Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted IDs []int64 `json:"ids"` // the records, in the same order + Data []string `json:"data"` // their data alone, in the same order + TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL Linked string `json:"linked,omitempty"` // shared or local, in a linked zone } @@ -63,7 +66,7 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow { } var first string for i, z := range zones { - vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}} + vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}, Data: []string{}} rs := slices.Clone(sets[i][k]) slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) }) for _, r := range rs { @@ -76,6 +79,10 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow { } vs.Records = append(vs.Records, d) vs.IDs = append(vs.IDs, r.ID) + vs.Data = append(vs.Data, r.Data) + if vs.TTL == nil && r.TTL != nil { + vs.TTL = model.Ptr(*r.TTL) + } } if z.LinkGroup != 0 && len(rs) > 0 { vs.Linked = "shared" @@ -102,3 +109,128 @@ func relName(name, zone string) string { } return strings.TrimSuffix(name, "."+zone) } + +// CompareReport is a zone in one view (the base) next to the same zone in +// other views (docs/views.md#comparing-views): what the other views answer +// differently, the names only some views have, and how much is the same. +type CompareReport struct { + Zone string `json:"zone"` + Base string `json:"base"` // the view compared from + Views []string `json:"views"` // the base first + Linked bool `json:"linked"` // all in one link + // Summary is, per other view, how it differs from the base. + Summary []CompareSummary `json:"summary"` + // Different are names and types more than one view has, with + // different records. + Different []CompareRow `json:"different"` + // Only are the names and types only some views have, by the views + // that have them. + Only []CompareOnly `json:"only"` + Same int `json:"same"` + // SameRows are the ones that are the same (when asked for). + SameRows []CompareRow `json:"same_rows,omitempty"` +} + +// CompareSummary counts how a view differs from the base view. +type CompareSummary struct { + View string `json:"view"` + Different int `json:"different"` // both have it, with different records + OnlyBase int `json:"only_base"` // the base has it, this view doesn't + OnlyOther int `json:"only_other"` // this view has it, the base doesn't + Same int `json:"same"` +} + +// CompareOnly are names and types that only some views have. +type CompareOnly struct { + Views []string `json:"views"` + Rows []CompareRow `json:"rows"` +} + +// CompareFilter narrows a comparison to a name, and optionally a type. +type CompareFilter struct { + Name string // relative ("www", "@") or absolute + Type string + All bool // include what is the same +} + +// Compare builds the report for zones (the base first). Apex NS records are +// left out: they name the servers, which views often list differently on +// purpose. +func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport { + rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true} + for _, z := range zones { + rep.Views = append(rep.Views, st.ViewName(z)) + rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup + } + for _, v := range rep.Views[1:] { + rep.Summary = append(rep.Summary, CompareSummary{View: v}) + } + want := "" + if f.Name != "" { + if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil { + want = o + } + } + only := map[string]int{} + for _, row := range CompareZones(st, zones) { + if row.FQDN == zones[0].Name && row.Type == "NS" { + continue + } + if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) { + continue + } + base := row.Views[0] + for i, vs := range row.Views[1:] { + s := &rep.Summary[i] + switch { + case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records): + s.Same++ + case len(base.Records) > 0 && len(vs.Records) > 0: + s.Different++ + case len(base.Records) > 0: + s.OnlyBase++ + case len(vs.Records) > 0: + s.OnlyOther++ + } + } + if row.Same { + rep.Same++ + if f.All || want != "" { + rep.SameRows = append(rep.SameRows, row) + } + continue + } + var having []string + versions := map[string]bool{} + for _, vs := range row.Views { + if len(vs.Records) > 0 { + having = append(having, vs.View) + versions[strings.Join(vs.Records, "\n")] = true + } + } + if len(versions) > 1 { + rep.Different = append(rep.Different, row) + continue + } + k := strings.Join(having, ",") + i, ok := only[k] + if !ok { + i = len(rep.Only) + only[k] = i + rep.Only = append(rep.Only, CompareOnly{Views: having}) + } + rep.Only[i].Rows = append(rep.Only[i].Rows, row) + } + // The base's own names first, then by how many views have them. + slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int { + ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base) + if ab != bb { + if ab { + return -1 + } + return 1 + } + return len(b.Views) - len(a.Views) + }) + return rep +} diff --git a/internal/engine/linked_test.go b/internal/engine/linked_test.go index 1ae6908..0f059ee 100644 --- a/internal/engine/linked_test.go +++ b/internal/engine/linked_test.go @@ -341,3 +341,48 @@ func TestCompareZones(t *testing.T) { t.Errorf("sharing the shared version: external %q", v) } } + +func TestCompareReport(t *testing.T) { + env := linkedEnv(t) + env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) + env.submit(env.admin, + NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}), + NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}), + NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}), + NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)})) + st := env.state() + zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)} + + rep := Compare(st, zones, CompareFilter{}) + if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) { + t.Errorf("report %+v", rep) + } + if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 { + t.Errorf("summary %+v", s) + } + if len(rep.Different) != 1 || rep.Different[0].Name != "www" { + t.Errorf("different %+v", rep.Different) + } + var only []string + for _, o := range rep.Only { + var names []string + for _, r := range o.Rows { + names = append(names, r.Name) + } + only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ",")) + } + want(t, "only", only, "internal: nas,printer", "external: shop") + if rep.Same != 2 || rep.SameRows != nil { + t.Errorf("same %d, rows %v", rep.Same, rep.SameRows) + } + + // One name: just its rows, the same ones included. + rep = Compare(st, zones, CompareFilter{Name: "www"}) + if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 { + t.Errorf("www: %+v", rep) + } + rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"}) + if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 { + t.Errorf("mail A: %+v", rep) + } +} diff --git a/internal/server/h_compare.go b/internal/server/h_compare.go index b35a684..6f71a46 100644 --- a/internal/server/h_compare.go +++ b/internal/server/h_compare.go @@ -9,15 +9,29 @@ import ( "jarl/internal/model" ) -// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across -// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the -// views (default: every view with a primary zone of that name), ?all=1 -// includes the names and types that are the same everywhere. +// zoneCompare: GET /zones/{zone}/compare compares a zone with the same +// zone in other views (docs/views.md#comparing-views), from the zone's own +// view: ?with=a,b picks the other views (default: every view with a +// primary zone of that name), ?name=www[&type=A] narrows it to one name, +// ?all=1 includes what is the same. A zone named without its view is +// compared from the first view that has it. (?views=a,b, the views to +// compare with the first as the base, works too.) func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } + q := r.URL.Query() + list := func(k string) []string { + var out []string + for _, v := range strings.Split(q.Get(k), ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out + } + with, views := list("with"), list("views") z, err := s.zoneByPath(r, st, p) if err != nil { // A name in several views is what there is to compare. @@ -26,56 +40,45 @@ func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Pri ref = "." } name, perr := engine.ParseZoneName(ref) - if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 { + zs := st.ZonesNamed(name) + if perr != nil || len(zs) < 2 { return err - } else { - z = zs[0] + } + st.SortByView(zs) + z = zs[0] + if len(views) > 0 { + for _, o := range zs { + if strings.EqualFold(st.ViewName(o), views[0]) { + z = o + } + } } } - var want []string - for _, v := range strings.Split(r.URL.Query().Get("views"), ",") { - if v = strings.TrimSpace(v); v != "" { - want = append(want, v) - } + readable := func(o *model.Zone) bool { + vn := st.ViewName(o) + return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) } - var zones []*model.Zone + if !readable(z) { + return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name)) + } + zones := []*model.Zone{z} + var others []*model.Zone for _, o := range st.ZonesNamed(z.Name) { vn := st.ViewName(o) - if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) { + if o.ID == z.ID || !readable(o) { continue } - if len(want) > 0 && !containsFold(want, vn) { + if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) { continue } - zones = append(zones, o) + others = append(others, o) } - st.SortByView(zones) + st.SortByView(others) + zones = append(zones, others...) if len(zones) < 2 { return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name)) } - rows := engine.CompareZones(st, zones) - all := queryBool(r, "all") - out := struct { - Zone string `json:"zone"` - Views []string `json:"views"` - Linked bool `json:"linked"` // all of them in one link - Same int `json:"same"` // names and types the same everywhere - Rows []engine.CompareRow `json:"rows"` - }{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true} - for _, o := range zones { - out.Views = append(out.Views, st.ViewName(o)) - out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup - } - for _, row := range rows { - if row.Same { - out.Same++ - if !all { - continue - } - } - out.Rows = append(out.Rows, row) - } - writeJSON(w, http.StatusOK, out) + writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")})) return nil } diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 3d213a8..6631db4 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -1,5 +1,5 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js"; -import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js"; +import { api, get, stream, change, changeMany, describeResult, rrtypes, state, draftQuery, can } from "../api.js"; import { rerender } from "../app.js"; import { optionsEditor } from "./options.js"; import { emailOf, pendingBadge } from "./zones.js"; @@ -70,7 +70,7 @@ export async function render(el, params, query) { else if (tab === "servers") await serversTab(body, zone); else if (tab === "dnssec") await dnssecTab(body, zone); else if (tab === "registrar") await registrarTab(body, zone); - else if (tab === "compare") await compareTab(body, zone); + else if (tab === "compare") await compareTab(body, zone, query); // A later click (another tab) has taken over the page. if (seq !== renderSeq || !holder.isConnected) return; holder.replaceWith(body); @@ -82,34 +82,110 @@ export async function render(el, params, query) { // compareTab shows the names and types that differ between the views that // have the zone, with a button to share one view's version (linked zones). -async function compareTab(el, zone) { - let all = false; +// compareTab compares the zone with the same zone in other views, from +// this one (docs/views.md#comparing-views): a summary, what the views +// answer differently, the names only some views have, and how much is +// the same. ?name= (and &type=) narrows it to one name. +async function compareTab(el, zone, query) { + const others = zone.other_views || []; + const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || ""); + const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } }); + const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } }); + let showSame = false; const out = h("div"); + const one = (d) => h("div", { class: "oneline mono small", title: d }, d); const draw = async () => { - const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" }); - const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v))); - const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" }, - h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)), - row.views.map((vs) => h("td", { class: "compare-cell" }, - vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"), - vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null, - r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced", - onclick: () => share(vs, row) }, "Use this version") : null)))); - mount(out, - h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ", - r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."), - r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."), - h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too")); + const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined }; + let r; + try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; } + const oneName = !!q.name; + const parts = []; + parts.push(h("p", { class: "muted" }, r.linked + ? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose." + : "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step.")); + if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null, + h("strong", null, s.view), ": ", + [[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]] + .map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)]))))); + + if (r.different.length) { + parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"), + h("div", { class: "table-wrap" }, h("table", { class: "compare" }, + h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))), + h("tbody", null, r.different.map((row) => h("tr", null, + h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)), + row.views.map((vs) => h("td", { class: "compare-cell" }, + vs.records.length ? vs.records.map(one) : h("span", { class: "muted" }, "–"), + vs.records.length ? h("div", { class: "compare-actions" }, + vs.linked === "local" ? [badge("own records", "accent"), " "] : null, + h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null)))))))); + } + for (const o of r.only) { + const missing = r.views.filter((v) => !o.views.includes(v)); + parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 }, + h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")", + h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")), + h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => { + const src = row.views.find((vs) => vs.records.length); + return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)), + h("td", { class: "compare-cell" }, src.records.map(one)), + h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v)))); + })))))); + } + if (r.same_rows && r.same_rows.length) { + parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"), + h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" }, + h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one)))))))); + } else if (!oneName && r.same) { + parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them"))); + } + if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views.")); + else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs.")); + mount(out, parts); }; - const share = async (vs, row) => { - if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return; + // useEverywhere gives every view one view's records of a name and type: + // in a linked zone by sharing them, otherwise by setting each zone's. + const useEverywhere = async (r, row, vs) => { + const rest = row.views.filter((x) => x !== vs); + const linkedAll = r.linked && zone.linked_with; + const msg = linkedAll + ? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced." + : rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + "."; + if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return; try { - toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false }))); + const res = linkedAll + ? await change("PATCH", "/records/" + vs.ids[0], { local: false }) + : await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type); + toast(describeResult(res)); await draw(); } catch (e) { toast(e.message, "err", 8000); } }; + const copyTo = async (row, src, dst) => { + try { + toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view))); + await draw(); + } catch (e) { toast(e.message, "err", 8000); } + }; + const go = () => draw(); + withSel.addEventListener("change", go); + nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); }); + typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); }); + mount(el, h("div", { class: "panel" }, + h("div", { class: "compare-controls" }, + h("label", null, "Compare " + zone.view + " with "), withSel, " ", + h("label", null, " Name "), nameIn, typeIn, " ", + h("button", { class: "small", onclick: go }, "Compare"), + nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null), + out)); await draw(); - mount(el, out); +} + +// rrsetFrom is the request giving zone dst a name and type's records from +// src: its own (local) records, in a linked zone. +function rrsetFrom(row, src, dst) { + const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true }; + if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl; + return d; } // ---- linked zones (docs/views.md#linked-zones) @@ -610,6 +686,7 @@ async function recordsTab(el, zone, query) { : h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null, !reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled ? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null, + zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null, h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", r.disabled ? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable") diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 71eae9f..33d368f 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -564,8 +564,20 @@ ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); } ul.probes > li .msg { margin: 6px 0 0; } /* Comparing a zone across views. */ -table.compare td.compare-cell { vertical-align: top; } +table.compare td { vertical-align: top; } +/* The same name and type columns in every section, so they line up. */ +table.compare { table-layout: auto; width: 100%; } +table.compare tr > :nth-child(1) { width: 18ch; } +table.compare tr > :nth-child(2) { width: 8ch; } table.compare td.compare-cell button { margin-top: 4px; } +/* One line per record: a long TXT record is cut, with the whole of it on hover. */ +table.compare .oneline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46ch; } +.compare-actions { margin-top: 4px; } +.compare-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; } +.compare-controls label { color: var(--muted); } +ul.compare-summary { margin: 0 0 12px; padding-left: 18px; } +details.compare-only { margin: 12px 0; } +details.compare-only summary { cursor: pointer; padding: 4px 0; } table.compare tr.muted td { color: var(--muted); } /* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */ diff --git a/scripts/e2e.sh b/scripts/e2e.sh index a2529e4..e1f180f 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -418,11 +418,28 @@ expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all contains "linked with internal" expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A -run "zone diff: what differs between the views" 0 $J zone diff adbraun.test -contains "ns1 *A *127.0.0.1 \\[local, id [0-9]*\\] *203.0.113.1 \\[local, id [0-9]*\\]" -contains "names and types differ, [0-9]* are the same; linked" -run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&all=1" +run "zone diff: internal compared with external" 0 $J zone diff adbraun.test +contains "^adbraun.test in internal, compared with external$" +contains "^ linked: a difference is a view's own records" +contains "^ external: [1-9][0-9]* answered differently, [0-9]* only in internal, [0-9]* only in external, [0-9]* the same" +contains "^ANSWERED DIFFERENTLY" +contains "^ ns1 A$" +contains "^ internal 127.0.0.1 \\[local, id [0-9]*\\]" +contains "^ external 203.0.113.1 \\[local, id [0-9]*\\]" +contains "the same (--all lists them)" +run "zone diff from external" 0 $J zone diff adbraun.test --view external +contains "^adbraun.test in external, compared with internal" +run "zone diff for one name" 0 $J zone diff adbraun.test ns1 A +contains "^ ns1 A$" +not_contains "the same (--all" +run "a long TXT record" 0 $J record add adbraun.test longtxt TXT "$(printf 'x%.0s' $(seq 300))" --view internal --local +run "zone diff --wide shows it whole" 0 $J zone diff adbraun.test longtxt --wide +contains "x\\{250\\}" +run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&with=external&all=1" contains '"linked":true' +contains '"base":"internal"' +contains '"same_rows":\[' +run "remove the long TXT" 0 $J record delete adbraun.test longtxt TXT --view internal -y run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y expect_dig "gone from internal too" "" linked.adbraun.test A run "zone unlink" 0 $J zone unlink adbraun.test --view external