Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace
Zones, address redirects, inline rules, lists and groups are cluster objects now (see the previous commits and deploy/MIGRATION.md), so the mechanisms that held them are gone: * internal/kubezones and the resolver's service-account access to a ConfigMap, the address-overrides read/write path and its poller, the zone-file poller and the Local zones editor with its /api/local-zones routes. * localzone.Managed, the directory of console-written master files, and the blocklist.managed_zones key. The resolver now writes no zone data at all; its zone hooks only list. The shared half of that file (the record validator, the renderer and the round-trip check) stays as zonestate.go, because DNSZone rendering is built on it. Its tests are ported to RenderZone, which keeps the coverage of every editable record type, wildcards, relative owners, the SOA/NS rule and the delegation rule. * Tests of the removed write paths: the contract test now builds its zone from a DNSZone, and a new test pins that every zone write route on the resolver is refused and changes nothing. Standalone installs keep editing master files on disk and SIGHUP, and the YAML-backed domain, client, upstream and settings editors, which have not moved. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f25463347
commit
467733a7f9
21 files changed
+505
-2312
No files matched your search
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,170 +13,6 @@ import (
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// TestZoneConsoleRoundTrip is the console's zone page, end to end: a record
|
||||
// added through the admin API is answered by this resolver on the next query,
|
||||
// with no reload and no restart, because a zone the console created is
|
||||
// compiled into the same store the operator's master files go into.
|
||||
func TestZoneConsoleRoundTrip(t *testing.T) {
|
||||
managed := filepath.Join(t.TempDir(), "zones")
|
||||
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
|
||||
|
||||
do := func(method, path, body string) (int, string) {
|
||||
t.Helper()
|
||||
code, out := h.do(method, path, body)
|
||||
return code, string(out)
|
||||
}
|
||||
mustWrite := func(method, path, body string) string {
|
||||
t.Helper()
|
||||
code, out := do(method, path, body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("%s %s = %d: %s", method, path, code, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Before anything is written the page is empty but honest: no zones, the
|
||||
// managed directory named, and the types the API accepts so the console
|
||||
// builds its picker from the server.
|
||||
_, body := h.get("/api/zones")
|
||||
var empty zonesPayload
|
||||
if err := json.Unmarshal(body, &empty); err != nil {
|
||||
t.Fatalf("zones: %v: %s", err, body)
|
||||
}
|
||||
// The operator's own zone is listed before the console has written
|
||||
// anything, and it is not the console's to edit.
|
||||
if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." {
|
||||
t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones)
|
||||
}
|
||||
if empty.Zones[0].Managed {
|
||||
t.Error("the operator's zone is reported as managed")
|
||||
}
|
||||
if empty.ManagedDir != managed {
|
||||
t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed)
|
||||
}
|
||||
if !empty.Writable {
|
||||
t.Error("writable = false, but the harness's temp dir is writable")
|
||||
}
|
||||
if len(empty.Types) == 0 {
|
||||
t.Error("types is empty, so the console has no record types to offer")
|
||||
}
|
||||
|
||||
// A zone starts with a server list: without one it cannot say NXDOMAIN,
|
||||
// and the writer refuses to invent it.
|
||||
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
|
||||
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
|
||||
|
||||
m := h.query("www.console.test", dns.TypeA)
|
||||
if len(m.Answer) != 1 {
|
||||
t.Fatalf("query www.console.test = %v, want one answer", m)
|
||||
}
|
||||
a, ok := m.Answer[0].(*dns.A)
|
||||
if !ok || a.A.String() != "10.9.9.9" {
|
||||
t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0])
|
||||
}
|
||||
if m.Authoritative != true {
|
||||
t.Error("the local answer is not authoritative")
|
||||
}
|
||||
|
||||
// The file is on disk where the operator can read it, and it is a zone
|
||||
// file rather than a JSON dump.
|
||||
raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone"))
|
||||
if err != nil {
|
||||
t.Fatalf("read the managed zone file: %v", err)
|
||||
}
|
||||
for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("the zone file is missing %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A delegation is a cut: the parent stops answering below it and the
|
||||
// query goes upstream, which is the whole point of making one.
|
||||
mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
|
||||
m = h.query("www.sub.console.test", dns.TypeA)
|
||||
if len(m.Answer) != 1 {
|
||||
t.Fatalf("query below the cut = %v, want the upstream answer", m)
|
||||
}
|
||||
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
|
||||
t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0])
|
||||
}
|
||||
// And the cut itself is answerable, or no resolver could ever learn it.
|
||||
m = h.query("sub.console.test", dns.TypeNS)
|
||||
if len(m.Answer) != 1 {
|
||||
t.Errorf("NS at the cut = %v, want the delegation itself", m)
|
||||
}
|
||||
|
||||
// A record below a cut is dead data, so it is refused rather than
|
||||
// written and silently never answered.
|
||||
if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest {
|
||||
t.Errorf("a record below a delegation = %d, want 400", code)
|
||||
}
|
||||
|
||||
// Deleting the record leaves the zone authoritative, and a zone that
|
||||
// exists says NXDOMAIN for the names it does not hold. That is what
|
||||
// creating a zone means, and it is why deleting a zone is a separate
|
||||
// action below rather than a side effect of deleting a record.
|
||||
mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "")
|
||||
m = h.query("www.console.test", dns.TypeA)
|
||||
if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 {
|
||||
t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m)
|
||||
}
|
||||
if m.Authoritative != true {
|
||||
t.Error("the NXDOMAIN is not authoritative, so the zone is not serving")
|
||||
}
|
||||
|
||||
// Deleting something that is not there is a 404, not a 500.
|
||||
if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound {
|
||||
t.Errorf("deleting an absent record = %d, want 404", code)
|
||||
}
|
||||
|
||||
// Removing the zone is what hands the name back to upstream, and the
|
||||
// cached NXDOMAIN goes with it.
|
||||
mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "")
|
||||
if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) {
|
||||
t.Errorf("the managed zone file is still there (stat err = %v)", err)
|
||||
}
|
||||
m = h.query("www.console.test", dns.TypeA)
|
||||
if len(m.Answer) != 1 {
|
||||
t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m)
|
||||
}
|
||||
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
|
||||
t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0])
|
||||
}
|
||||
// And the zone is gone from the page.
|
||||
_, body = h.get("/api/zones")
|
||||
var gone zonesPayload
|
||||
if err := json.Unmarshal(body, &gone); err != nil {
|
||||
t.Fatalf("zones: %v: %s", err, body)
|
||||
}
|
||||
for _, zone := range gone.Zones {
|
||||
if zone.Origin == "console.test." {
|
||||
t.Errorf("the deleted zone is still listed: %+v", zone)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator's zone is not the console's to delete.
|
||||
if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound {
|
||||
t.Errorf("deleting the operator's zone = %d, want 404", code)
|
||||
}
|
||||
|
||||
// The operator's master file is still theirs: it is listed, and it is not
|
||||
// reported as console-managed.
|
||||
var sawLocal bool
|
||||
for _, zone := range gone.Zones {
|
||||
if zone.Origin != "local." {
|
||||
continue
|
||||
}
|
||||
sawLocal = true
|
||||
if zone.Managed {
|
||||
t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it")
|
||||
}
|
||||
}
|
||||
if !sawLocal {
|
||||
t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones)
|
||||
}
|
||||
}
|
||||
|
||||
// zonesPayload is the slice of GET /api/zones this test reads. It is declared
|
||||
// here rather than imported so the assertions name the fields the console
|
||||
// depends on; the contract test in this package pins the whole key set.
|
||||
@@ -202,10 +36,11 @@ type zonesPayload struct {
|
||||
Records int `json:"records"`
|
||||
}
|
||||
|
||||
// TestZoneWritesNeedAManagedDirectory pins the read-only default: a
|
||||
// deployment that never sets blocklist.managed_zones can browse its local zones
|
||||
// and cannot have one rewritten behind its back.
|
||||
func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
|
||||
// TestZonesAreReadOnlyOnTheResolver: the resolver lists the zones it serves and
|
||||
// writes none of them. Editing a zone is the console's job and happens on the
|
||||
// DNSZone objects (see console_e2e_test.go); every write route on the
|
||||
// resolver answers "not available" and changes nothing.
|
||||
func TestZonesAreReadOnlyOnTheResolver(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
code, body := h.get("/api/zones")
|
||||
if code != http.StatusOK {
|
||||
@@ -215,43 +50,29 @@ func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
|
||||
if err := json.Unmarshal(body, &zones); err != nil {
|
||||
t.Fatalf("zones: %v: %s", err, body)
|
||||
}
|
||||
if zones.Writable {
|
||||
t.Error("writable = true with no managed zone directory configured")
|
||||
if zones.Writable || zones.ManagedDir != "" {
|
||||
t.Errorf("writable=%v managed_dir=%q on a resolver with no Kubernetes source", zones.Writable, zones.ManagedDir)
|
||||
}
|
||||
if zones.ManagedDir != "" {
|
||||
t.Errorf("managed_dir = %q, want empty", zones.ManagedDir)
|
||||
if len(zones.Zones) != 1 || zones.Zones[0].Origin != "local." || zones.Zones[0].Managed {
|
||||
t.Errorf("zones = %+v, want only the operator's master file, not managed", zones.Zones)
|
||||
}
|
||||
if len(zones.Zones) == 0 {
|
||||
t.Error("the operator's local zone is missing, so the page would be blank rather than read-only")
|
||||
}
|
||||
for _, zone := range zones.Zones {
|
||||
if zone.Managed {
|
||||
t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin)
|
||||
}
|
||||
}
|
||||
// Every write route refuses, and says what to configure.
|
||||
writes := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
for _, w := range []struct{ method, path, body string }{
|
||||
{http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`},
|
||||
{http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`},
|
||||
{http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""},
|
||||
{http.MethodDelete, "/api/zones/records?zone=local.&name=ns.local.&type=A", ""},
|
||||
{http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`},
|
||||
{http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""},
|
||||
{http.MethodDelete, "/api/zones?zone=console.test.", ""},
|
||||
}
|
||||
for _, w := range writes {
|
||||
{http.MethodDelete, "/api/zones/delegations?zone=local.&name=sub.local.", ""},
|
||||
{http.MethodDelete, "/api/zones?zone=local.", ""},
|
||||
{http.MethodGet, "/api/local-zones", ""},
|
||||
} {
|
||||
code, out := h.do(w.method, w.path, w.body)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(string(out), "managed_zones") {
|
||||
t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out)
|
||||
if code != http.StatusNotImplemented && code != http.StatusNotFound && code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s = %d, want it refused: %s", w.method, w.path, code, out)
|
||||
}
|
||||
}
|
||||
if m := h.query("ns.local", dns.TypeA); len(m.Answer) != 1 {
|
||||
t.Errorf("the operator's zone stopped answering after the refused writes: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
|
||||
|
||||
Reference in new issue
Block a user