The runtime can now take ClientGroup, BlockList and DNSZone objects (fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service account or a kubeconfig alike. internal/kubesource watches them with dynamic informers, converts and validates each object on its own, and publishes an immutable snapshot. A bad object is skipped and keeps its last good version, so one broken edit cannot remove a running policy; an invalid merged config is dropped whole. The last snapshot is saved so a restart without apiserver access still serves. config.Store gains an overlay: Base() is what the YAML says, Get() is base plus cluster objects. Everything that writes the config back (mutateConfig, SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster objects would be copied into the file and shadow their own objects. YAML wins every name collision. putACL and toggleBlocklist refuse to edit cluster-owned objects. BlockList has no path field and refuses file:// URLs so a namespaced object cannot pick files the runtime reads or writes. DNSZone is rendered through the zone writer's own prove-by-reparse step, with the SOA serial taken from metadata.generation so every replica renders identical bytes. qlog: Flush reset the pending slice to [:0], so entries logged while it was still storing the batch were appended over the same backing array. The race detector found it through the new Kubernetes integration test. pending is now set to nil, with a regression test that fails without the change. CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas to the Go spec structs so the two cannot drift. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
297 lines
10 KiB
Go
297 lines
10 KiB
Go
package kubesource
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
dynfake "k8s.io/client-go/dynamic/fake"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
|
|
)
|
|
|
|
func obj(kind, name string, spec map[string]any) *unstructured.Unstructured {
|
|
return &unstructured.Unstructured{Object: map[string]any{
|
|
"apiVersion": APIGroup + "/" + Version,
|
|
"kind": kind,
|
|
"metadata": map[string]any{"name": name, "namespace": "dns", "generation": int64(3)},
|
|
"spec": spec,
|
|
}}
|
|
}
|
|
|
|
func cgroup(name string, spec map[string]any) *unstructured.Unstructured {
|
|
return obj("ClientGroup", name, spec)
|
|
}
|
|
|
|
func fakeClient(objs ...runtime.Object) *dynfake.FakeDynamicClient {
|
|
return dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
|
|
map[schemaGVR]string{
|
|
ZoneGVR: "DNSZoneList",
|
|
GroupGVR: "ClientGroupList",
|
|
ListGVR: "BlockListList",
|
|
}, objs...)
|
|
}
|
|
|
|
func start(t *testing.T, objs ...runtime.Object) (*Source, *dynfake.FakeDynamicClient, chan *Snapshot) {
|
|
t.Helper()
|
|
c := fakeClient(objs...)
|
|
s, err := New(Options{Client: c, Namespace: "dns", Debounce: 10 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ch := make(chan *Snapshot, 16)
|
|
s.OnChange(func(sn *Snapshot) { ch <- sn })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { _ = s.Run(ctx); close(done) }()
|
|
t.Cleanup(func() { cancel(); <-done })
|
|
return s, c, ch
|
|
}
|
|
|
|
func next(t *testing.T, ch chan *Snapshot) *Snapshot {
|
|
t.Helper()
|
|
select {
|
|
case sn := <-ch:
|
|
return sn
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("no snapshot published")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var cidrMatch = map[string]any{"cidrs": []any{"10.0.0.0/8"}}
|
|
|
|
func TestGroupsAreOrderedByPriorityThenName(t *testing.T) {
|
|
_, _, ch := start(t,
|
|
cgroup("b-kids", map[string]any{"priority": int64(10), "policy": "block_only", "match": cidrMatch}),
|
|
cgroup("a-kids", map[string]any{"priority": int64(10), "policy": "deny", "match": cidrMatch}),
|
|
cgroup("first", map[string]any{"priority": int64(1), "policy": "allow", "match": cidrMatch}),
|
|
)
|
|
sn := next(t, ch)
|
|
var names []string
|
|
for _, g := range sn.Groups {
|
|
names = append(names, g.Name)
|
|
}
|
|
if got := strings.Join(names, ","); got != "first,a-kids,b-kids" {
|
|
t.Fatalf("order = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestRejectedObjectIsReportedAndOthersSurvive(t *testing.T) {
|
|
_, _, ch := start(t,
|
|
cgroup("good", map[string]any{"policy": "allow", "match": cidrMatch}),
|
|
cgroup("badpolicy", map[string]any{"policy": "maybe", "match": cidrMatch}),
|
|
cgroup("badcidr", map[string]any{"policy": "deny", "match": map[string]any{"cidrs": []any{"10.0.0.0/33"}}}),
|
|
cgroup("empty", map[string]any{"policy": "deny", "match": map[string]any{}}),
|
|
)
|
|
sn := next(t, ch)
|
|
if len(sn.Groups) != 1 || sn.Groups[0].Name != "good" {
|
|
t.Fatalf("groups = %+v", sn.Groups)
|
|
}
|
|
if len(sn.Problems) != 3 {
|
|
t.Fatalf("problems = %+v", sn.Problems)
|
|
}
|
|
}
|
|
|
|
func TestBrokenEditKeepsLastGoodVersion(t *testing.T) {
|
|
_, c, ch := start(t, cgroup("kids", map[string]any{"policy": "deny", "match": cidrMatch}))
|
|
if sn := next(t, ch); sn.Groups[0].Policy != "deny" {
|
|
t.Fatalf("first = %+v", sn.Groups)
|
|
}
|
|
// A bad edit changes nothing in force, so it publishes nothing...
|
|
bad := cgroup("kids", map[string]any{"policy": "bogus", "match": cidrMatch})
|
|
if _, err := c.Resource(GroupGVR).Namespace("dns").Update(context.Background(), bad, metav1UpdateOptions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case sn := <-ch:
|
|
t.Fatalf("a rejected edit published a snapshot: %+v", sn.Groups)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
// ...and a good edit goes through.
|
|
good := cgroup("kids", map[string]any{"policy": "block_only", "match": cidrMatch})
|
|
if _, err := c.Resource(GroupGVR).Namespace("dns").Update(context.Background(), good, metav1UpdateOptions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sn := next(t, ch); sn.Groups[0].Policy != "block_only" {
|
|
t.Fatalf("after good edit = %+v", sn.Groups)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRemovesGroup(t *testing.T) {
|
|
_, c, ch := start(t, cgroup("kids", map[string]any{"policy": "deny", "match": cidrMatch}))
|
|
next(t, ch)
|
|
if err := c.Resource(GroupGVR).Namespace("dns").Delete(context.Background(), "kids", metav1DeleteOptions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sn := next(t, ch); len(sn.Groups) != 0 {
|
|
t.Fatalf("groups = %+v", sn.Groups)
|
|
}
|
|
}
|
|
|
|
func TestBlockListConversion(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
spec map[string]any
|
|
ok bool
|
|
}{
|
|
"https": {map[string]any{"url": "https://example.com/hosts", "format": "hosts", "refresh": "6h"}, true},
|
|
"file url": {map[string]any{"url": "file:///etc/shadow"}, false},
|
|
"no url": {map[string]any{"format": "hosts"}, false},
|
|
"bad format": {map[string]any{"url": "https://e.com/x", "format": "yaml"}, false},
|
|
"bad refresh": {map[string]any{"url": "https://e.com/x", "refresh": "soon"}, false},
|
|
"bad mirror": {map[string]any{"url": "https://e.com/x", "mirror": []any{"file:///x"}}, false},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
l, err := convertList(obj("BlockList", "ads", tc.spec))
|
|
if (err == nil) != tc.ok {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if tc.ok && (!l.Enabled || l.Path != "" || l.Refresh != config.Duration(6*time.Hour)) {
|
|
t.Fatalf("list = %+v", l)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func zoneSpec(records ...any) map[string]any {
|
|
return map[string]any{"zone": "Home.Example.com", "records": records}
|
|
}
|
|
|
|
func rec(name, typ, value string) map[string]any {
|
|
return map[string]any{"name": name, "type": typ, "value": value}
|
|
}
|
|
|
|
func TestZoneRendersAndRoundTrips(t *testing.T) {
|
|
z, err := convertZone(obj("DNSZone", "home", zoneSpec(
|
|
rec("@", "NS", "ns1.home.example.com."),
|
|
rec("ns1", "A", "10.0.0.1"),
|
|
rec("nas", "A", "10.0.0.2"),
|
|
rec("*.apps", "A", "10.0.0.3"),
|
|
)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if z.apex != "home.example.com." {
|
|
t.Fatalf("apex = %q", z.apex)
|
|
}
|
|
parsed, err := zonefile.Parse(strings.NewReader(z.text), zonefile.Options{Origin: z.apex, File: "t"})
|
|
if err != nil {
|
|
t.Fatalf("rendered zone does not parse: %v\n%s", err, z.text)
|
|
}
|
|
if parsed.SOA == nil || parsed.SOA.Serial != 3 {
|
|
t.Fatalf("SOA serial should be the object generation: %+v", parsed.SOA)
|
|
}
|
|
// Same input, same bytes: replicas must agree.
|
|
again, _ := convertZone(obj("DNSZone", "home", zoneSpec(
|
|
rec("@", "NS", "ns1.home.example.com."),
|
|
rec("ns1", "A", "10.0.0.1"),
|
|
rec("nas", "A", "10.0.0.2"),
|
|
rec("*.apps", "A", "10.0.0.3"),
|
|
)))
|
|
if again.text != z.text {
|
|
t.Fatal("rendering is not deterministic")
|
|
}
|
|
store, err := localzone.Load(writeTemp(t, z), localzone.Options{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.Count() == 0 {
|
|
t.Fatal("store loaded no records")
|
|
}
|
|
}
|
|
|
|
func TestZoneRejections(t *testing.T) {
|
|
for name, spec := range map[string]map[string]any{
|
|
"no apex NS": zoneSpec(rec("nas", "A", "10.0.0.2")),
|
|
"bad value": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("nas", "A", "not-an-ip")),
|
|
"outside zone": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("x.other.org.", "A", "10.0.0.2")),
|
|
"duplicate": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("a", "A", "10.0.0.2"), rec("a", "A", "10.0.0.2")),
|
|
"under delegated": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("sub", "NS", "ns.else.net."), rec("x.sub", "A", "10.0.0.2")),
|
|
"soa as record": zoneSpec(rec("@", "NS", "ns1.home.example.com."), rec("@", "SOA", "a. b. 1 2 3 4 5")),
|
|
"no zone": {"records": []any{}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := convertZone(obj("DNSZone", "z", spec)); err == nil {
|
|
t.Fatal("expected a rejection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDuplicateApexOldestWins(t *testing.T) {
|
|
a := obj("DNSZone", "a", zoneSpec(rec("@", "NS", "ns1.home.example.com.")))
|
|
b := obj("DNSZone", "b", zoneSpec(rec("@", "NS", "ns2.home.example.com.")))
|
|
a.SetCreationTimestamp(metav1Time(1000))
|
|
b.SetCreationTimestamp(metav1Time(2000))
|
|
_, _, ch := start(t, b, a)
|
|
sn := next(t, ch)
|
|
if len(sn.Zones) != 1 || !strings.Contains(sn.Zones["home.example.com."], "ns1.home.example.com.") {
|
|
t.Fatalf("zones = %v", sn.Zones)
|
|
}
|
|
if len(sn.Problems) != 1 || sn.Problems[0].Name != "b" {
|
|
t.Fatalf("problems = %+v", sn.Problems)
|
|
}
|
|
}
|
|
|
|
func TestMergePrecedenceAndDefaults(t *testing.T) {
|
|
base := config.Default()
|
|
base.ACL.Groups = []config.Group{{Name: "yaml", Policy: "allow", Match: config.Match{CIDRs: []string{"192.168.0.0/16"}}}}
|
|
base.Block.Lists = []config.ListSource{{Name: "ads", URL: "https://a/x", Enabled: true}}
|
|
snap := &Snapshot{
|
|
Groups: []config.Group{{Name: "yaml", Policy: "deny"}, {Name: "crd", Policy: "deny"}},
|
|
Lists: []config.ListSource{{Name: "ads", URL: "https://b/x"}, {Name: "crd-list", URL: "https://c/x", Enabled: true}},
|
|
}
|
|
merged, dropped := Merge(base, snap)
|
|
if len(merged.ACL.Groups) != 2 || merged.ACL.Groups[0].Policy != "allow" || merged.ACL.Groups[1].Name != "crd" {
|
|
t.Fatalf("groups = %+v", merged.ACL.Groups)
|
|
}
|
|
if len(merged.Block.Lists) != 2 || merged.Block.Lists[0].URL != "https://a/x" {
|
|
t.Fatalf("lists = %+v", merged.Block.Lists)
|
|
}
|
|
if got := merged.Block.Lists[1]; got.Format != "auto" || got.Refresh == 0 || got.MaxBytes == 0 {
|
|
t.Fatalf("defaults not applied: %+v", got)
|
|
}
|
|
if len(dropped) != 2 {
|
|
t.Fatalf("dropped = %v", dropped)
|
|
}
|
|
if len(base.ACL.Groups) != 1 || len(base.Block.Lists) != 1 {
|
|
t.Fatal("Merge modified its input")
|
|
}
|
|
}
|
|
|
|
func TestSyncZoneDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x", "b.example.": "y"}); err != nil || !ch {
|
|
t.Fatalf("first sync: %v %v", ch, err)
|
|
}
|
|
if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x", "b.example.": "y"}); err != nil || ch {
|
|
t.Fatalf("idempotent sync: %v %v", ch, err)
|
|
}
|
|
if ch, err := SyncZoneDir(dir, map[string]string{"a.example.": "x"}); err != nil || !ch {
|
|
t.Fatalf("removal sync: %v %v", ch, err)
|
|
}
|
|
if got := fileNames(t, dir); got != "a.example.zone" {
|
|
t.Fatalf("files = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotSaveLoad(t *testing.T) {
|
|
path := t.TempDir() + "/sub/snap.json"
|
|
in := &Snapshot{Revision: 4, Groups: []config.Group{{Name: "g", Policy: "allow"}}, Zones: map[string]string{"a.": "z"}}
|
|
if err := Save(path, in); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := Load(path)
|
|
if err != nil || out.Revision != 4 || out.Groups[0].Name != "g" || out.Zones["a."] != "z" {
|
|
t.Fatalf("%+v %v", out, err)
|
|
}
|
|
if none, err := Load(path + ".missing"); none != nil || err != nil {
|
|
t.Fatalf("missing file: %v %v", none, err)
|
|
}
|
|
}
|