package app import ( "context" "testing" "time" "github.com/miekg/dns" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" dynfake "k8s.io/client-go/dynamic/fake" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" ) func kubeObj(kind, name string, spec map[string]any) *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": kubesource.APIGroup + "/" + kubesource.Version, "kind": kind, "metadata": map[string]any{"name": name, "namespace": "dns", "generation": int64(1)}, "spec": spec, }} } func kubeClient() *dynfake.FakeDynamicClient { return dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList", }) } func eventually(t *testing.T, what string, ok func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if ok() { return } time.Sleep(25 * time.Millisecond) } t.Fatalf("timed out waiting for %s", what) } // TestKubeObjectsReachTheResolver is the point of the Kubernetes source: an // object applied to the cluster changes what this resolver answers, with no // reload, and deleting it takes the change back. func TestKubeObjectsReachTheResolver(t *testing.T) { client := kubeClient() h := newHarness(t, nil, func(o *Options) { o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: client, SyncTimeout: 5 * time.Second} }) ctx := context.Background() zones := client.Resource(kubesource.ZoneGVR).Namespace("dns") groups := client.Resource(kubesource.GroupGVR).Namespace("dns") answer := func() string { m := h.query("www.kube.test", dns.TypeA) if len(m.Answer) == 1 { if a, ok := m.Answer[0].(*dns.A); ok && m.Authoritative { return a.A.String() } } return "" } if _, err := zones.Create(ctx, kubeObj("DNSZone", "kube", map[string]any{ "zone": "kube.test", "records": []any{ map[string]any{"name": "@", "type": "NS", "value": "ns1.kube.test."}, map[string]any{"name": "ns1", "type": "A", "value": "10.1.2.1"}, map[string]any{"name": "www", "type": "A", "value": "10.1.2.3"}, }, }), metav1.CreateOptions{}); err != nil { t.Fatal(err) } eventually(t, "the DNSZone to be served", func() bool { return answer() == "10.1.2.3" }) // The cluster's data never lands in the YAML the console writes back. if got := len(h.app.cfg.Base().ACL.Groups); got != 0 { t.Fatalf("base has %d groups", got) } if _, err := groups.Create(ctx, kubeObj("ClientGroup", "no-loopback", map[string]any{ "policy": "deny", "match": map[string]any{"cidrs": []any{"127.0.0.0/8"}}, }), metav1.CreateOptions{}); err != nil { t.Fatal(err) } eventually(t, "the ClientGroup to refuse the client", func() bool { return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeRefused }) if len(h.app.cfg.Base().ACL.Groups) != 0 || len(h.app.cfg.Get().ACL.Groups) != 1 { t.Fatalf("base=%d effective=%d groups", len(h.app.cfg.Base().ACL.Groups), len(h.app.cfg.Get().ACL.Groups)) } // The console cannot overwrite a cluster-owned group. if _, err := h.app.putACL(ctx, nil, ""); err != nil { t.Fatalf("saving the console's own (empty) list: %v", err) } if len(h.app.cfg.Get().ACL.Groups) != 1 { t.Fatal("saving the file's groups removed the cluster's") } if err := groups.Delete(ctx, "no-loopback", metav1.DeleteOptions{}); err != nil { t.Fatal(err) } eventually(t, "the deleted ClientGroup to stop applying", func() bool { return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeSuccess }) if err := zones.Delete(ctx, "kube", metav1.DeleteOptions{}); err != nil { t.Fatal(err) } eventually(t, "the deleted DNSZone to stop being authoritative", func() bool { return answer() == "" }) }