From 44aa809cb01215c12c9464716902e7910655962f Mon Sep 17 00:00:00 2001 From: Irbe Krumina Date: Tue, 30 Apr 2024 20:18:23 +0100 Subject: [PATCH] cmd/{k8s-nameserver,k8s-operator},k8s-operator: add a kube nameserver, make operator deploy it (#11919) * cmd/k8s-nameserver,k8s-operator: add a nameserver that can resolve ts.net DNS names in cluster. Adds a simple nameserver that can respond to A record queries for ts.net DNS names. It can respond to queries from in-memory records, populated from a ConfigMap mounted at /config. It dynamically updates its records as the ConfigMap contents changes. It will respond with NXDOMAIN to queries for any other record types (AAAA to be implemented in the future). It can respond to queries over UDP or TCP. It runs a miekg/dns DNS server with a single registered handler for ts.net domain names. Queries for other domain names will be refused. The intended use of this is: 1) to allow non-tailnet cluster workloads to talk to HTTPS tailnet services exposed via Tailscale operator egress over HTTPS 2) to allow non-tailnet cluster workloads to talk to workloads in the same cluster that have been exposed to tailnet over their MagicDNS names but on their cluster IPs. DNSConfig CRD can be used to configure the operator to deploy kube nameserver (./cmd/k8s-nameserver) to cluster. Updates tailscale/tailscale#10499 Signed-off-by: Irbe Krumina --- Makefile | 8 + build_docker.sh | 16 + cmd/k8s-nameserver/main.go | 355 ++++++++++++++++++ cmd/k8s-nameserver/main_test.go | 227 +++++++++++ .../deploy/chart/templates/operator-rbac.yaml | 7 +- .../deploy/crds/tailscale.com_dnsconfigs.yaml | 96 +++++ .../deploy/manifests/nameserver/cm.yaml | 4 + .../deploy/manifests/nameserver/deploy.yaml | 37 ++ .../deploy/manifests/nameserver/sa.yaml | 6 + .../deploy/manifests/nameserver/svc.yaml | 16 + .../deploy/manifests/operator.yaml | 110 ++++++ cmd/k8s-operator/generate/main.go | 10 +- cmd/k8s-operator/generate/main_test.go | 6 + cmd/k8s-operator/nameserver.go | 275 ++++++++++++++ cmd/k8s-operator/nameserver_test.go | 118 ++++++ cmd/k8s-operator/operator.go | 30 +- cmd/k8s-operator/operator_test.go | 1 - cmd/k8s-operator/proxyclass.go | 2 - k8s-operator/api.md | 270 +++++++++++++ k8s-operator/apis/v1alpha1/register.go | 2 +- .../apis/v1alpha1/types_tsdnsconfig.go | 71 ++++ .../apis/v1alpha1/zz_generated.deepcopy.go | 156 ++++++++ k8s-operator/conditions.go | 26 +- k8s-operator/conditions_test.go | 1 - k8s-operator/tsdns.go | 17 + 25 files changed, 1853 insertions(+), 14 deletions(-) create mode 100644 cmd/k8s-nameserver/main.go create mode 100644 cmd/k8s-nameserver/main_test.go create mode 100644 cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml create mode 100644 cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml create mode 100644 cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml create mode 100644 cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml create mode 100644 cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml create mode 100644 cmd/k8s-operator/nameserver.go create mode 100644 cmd/k8s-operator/nameserver_test.go create mode 100644 k8s-operator/apis/v1alpha1/types_tsdnsconfig.go create mode 100644 k8s-operator/tsdns.go diff --git a/Makefile b/Makefile index 8f8bbe2b4..31c4fd80d 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,14 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh +publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO} + @test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1) + @test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1) + @test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1) + TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh + help: ## Show this help @echo "\nSpecify a command. The choices are:\n" @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' diff --git a/build_docker.sh b/build_docker.sh index 16753da77..43665172a 100755 --- a/build_docker.sh +++ b/build_docker.sh @@ -71,6 +71,22 @@ case "$TARGET" in --target="${PLATFORM}" \ /usr/local/bin/operator ;; + k8s-nameserver) + DEFAULT_REPOS="tailscale/k8s-nameserver" + REPOS="${REPOS:-${DEFAULT_REPOS}}" + go run github.com/tailscale/mkctr \ + --gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \ + --ldflags=" \ + -X tailscale.com/version.longStamp=${VERSION_LONG} \ + -X tailscale.com/version.shortStamp=${VERSION_SHORT} \ + -X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \ + --base="${BASE}" \ + --tags="${TAGS}" \ + --repos="${REPOS}" \ + --push="${PUSH}" \ + --target="${PLATFORM}" \ + /usr/local/bin/k8s-nameserver + ;; *) echo "unknown target: $TARGET" exit 1 diff --git a/cmd/k8s-nameserver/main.go b/cmd/k8s-nameserver/main.go new file mode 100644 index 000000000..e62cf9b71 --- /dev/null +++ b/cmd/k8s-nameserver/main.go @@ -0,0 +1,355 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +// k8s-nameserver is a simple nameserver implementation meant to be used with +// k8s-operator to allow to resolve magicDNS names associated with tailnet +// proxies in cluster. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "sync" + "syscall" + + "github.com/fsnotify/fsnotify" + "github.com/miekg/dns" + operatorutils "tailscale.com/k8s-operator" + "tailscale.com/util/dnsname" +) + +const ( + // tsNetDomain is the domain that this DNS nameserver has registered a handler for. + tsNetDomain = "ts.net" + // addr is the the address that the UDP and TCP listeners will listen on. + addr = ":1053" + + // The following constants are specific to the nameserver configuration + // provided by a mounted Kubernetes Configmap. The Configmap mounted at + // /config is the only supported way for configuring this nameserver. + defaultDNSConfigDir = "/config" + defaultDNSFile = "dns.json" + kubeletMountedConfigLn = "..data" +) + +// nameserver is a simple nameserver that responds to DNS queries for A records +// for ts.net domain names over UDP or TCP. It serves DNS responses from +// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with +// a ConfigMap mounted at /config that should contain the host records. It +// dynamically reconfigures its in-memory mappings as the contents of the +// mounted ConfigMap changes. +type nameserver struct { + // configReader returns the latest desired configuration (host records) + // for the nameserver. By default it gets set to a reader that reads + // from a Kubernetes ConfigMap mounted at /config, but this can be + // overridden in tests. + configReader configReaderFunc + // configWatcher is a watcher that returns an event when the desired + // configuration has changed and the nameserver should update the + // in-memory records. + configWatcher <-chan string + + mu sync.Mutex // protects following + // ip4 are the in-memory hostname -> IP4 mappings that the nameserver + // uses to respond to A record queries. + ip4 map[dnsname.FQDN][]net.IP +} + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + // Ensure that we watch the kube Configmap mounted at /config for + // nameserver configuration updates and send events when updates happen. + c := ensureWatcherForKubeConfigMap(ctx) + + ns := &nameserver{ + configReader: configMapConfigReader, + configWatcher: c, + } + + // Ensure that in-memory records get set up to date now and will get + // reset when the configuration changes. + ns.runRecordsReconciler(ctx) + + // Register a DNS server handle for ts.net domain names. Not having a + // handle registered for any other domain names is how we enforce that + // this nameserver can only be used for ts.net domains - querying any + // other domain names returns Rcode Refused. + dns.HandleFunc(tsNetDomain, ns.handleFunc()) + + // Listen for DNS queries over UDP and TCP. + udpSig := make(chan os.Signal) + tcpSig := make(chan os.Signal) + go listenAndServe("udp", addr, udpSig) + go listenAndServe("tcp", addr, tcpSig) + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + s := <-sig + log.Printf("OS signal (%s) received, shutting down", s) + cancel() // exit the records reconciler and configmap watcher goroutines + udpSig <- s // stop the UDP listener + tcpSig <- s // stop the TCP listener +} + +// handleFunc is a DNS query handler that can respond to A record queries from +// the nameserver's in-memory records. +// - If an A record query is received and the +// nameserver's in-memory records contain records for the queried domain name, +// return a success response. +// - If an A record query is received, but the +// nameserver's in-memory records do not contain records for the queried domain name, +// return NXDOMAIN. +// - If an A record query is received, but the queried domain name is not valid, return Format Error. +// - If a query is received for any other record type than A, return Not Implemented. +func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) { + h := func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + defer func() { + w.WriteMsg(m) + }() + if len(r.Question) < 1 { + log.Print("[unexpected] nameserver received a request with no questions") + m = r.SetRcodeFormatError(r) + return + } + // TODO (irbekrm): maybe set message compression + switch r.Question[0].Qtype { + case dns.TypeA: + q := r.Question[0].Name + fqdn, err := dnsname.ToFQDN(q) + if err != nil { + m = r.SetRcodeFormatError(r) + return + } + // The only supported use of this nameserver is as a + // single source of truth for MagicDNS names by + // non-tailnet Kubernetes workloads. + m.Authoritative = true + m.RecursionAvailable = false + + ips := n.lookupIP4(fqdn) + if ips == nil || len(ips) == 0 { + // As we are the authoritative nameserver for MagicDNS + // names, if we do not have a record for this MagicDNS + // name, it does not exist. + m = m.SetRcode(r, dns.RcodeNameError) + return + } + // TODO (irbekrm): TTL is currently set to 0, meaning + // that cluster workloads will not cache the DNS + // records. Revisit this in future when we understand + // the usage patterns better- is it putting too much + // load on kube DNS server or is this fine? + for _, ip := range ips { + rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip} + m.SetRcode(r, dns.RcodeSuccess) + m.Answer = append(m.Answer, rr) + } + case dns.TypeAAAA: + // TODO (irbekrm): implement IPv6 support. + // Kubernetes distributions that I am most familiar with + // default to IPv4 for Pod CIDR ranges and often many cases don't + // support IPv6 at all, so this should not be crucial for now. + fallthrough + default: + log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String()) + m.SetRcode(r, dns.RcodeNotImplemented) + } + } + return h +} + +// runRecordsReconciler ensures that nameserver's in-memory records are +// reset when the provided configuration changes. +func (n *nameserver) runRecordsReconciler(ctx context.Context) { + log.Print("updating nameserver's records from the provided configuration...") + if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts + log.Fatalf("error setting nameserver's records: %v", err) + } + log.Print("nameserver's records were updated") + go func() { + for { + select { + case <-ctx.Done(): + log.Printf("context cancelled, exiting records reconciler") + return + case <-n.configWatcher: + log.Print("configuration update detected, resetting records") + if err := n.resetRecords(); err != nil { + // TODO (irbekrm): this runs in a + // container that will be thrown away, + // so this should be ok. But maybe still + // need to ensure that the DNS server + // terminates connections more + // gracefully. + log.Fatalf("error resetting records: %v", err) + } + log.Print("nameserver records were reset") + } + } + }() +} + +// resetRecords sets the in-memory DNS records of this nameserver from the +// provided configuration. It does not check for the diff, so the caller is +// expected to ensure that this is only called when reset is needed. +func (n *nameserver) resetRecords() error { + dnsCfgBytes, err := n.configReader() + if err != nil { + log.Printf("error reading nameserver's configuration: %v", err) + return err + } + if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 { + log.Print("nameserver's configuration is empty, any in-memory records will be unset") + n.mu.Lock() + n.ip4 = make(map[dnsname.FQDN][]net.IP) + n.mu.Unlock() + return nil + } + dnsCfg := &operatorutils.Records{} + err = json.Unmarshal(dnsCfgBytes, dnsCfg) + if err != nil { + return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err) + } + + if dnsCfg.Version != operatorutils.Alpha1Version { + return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version) + } + + ip4 := make(map[dnsname.FQDN][]net.IP) + defer func() { + n.mu.Lock() + defer n.mu.Unlock() + n.ip4 = ip4 + }() + + if len(dnsCfg.IP4) == 0 { + log.Print("nameserver's configuration contains no records, any in-memory records will be unset") + return nil + } + + for fqdn, ips := range dnsCfg.IP4 { + fqdn, err := dnsname.ToFQDN(fqdn) + if err != nil { + log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err) + continue // one invalid hostname should not break the whole nameserver + } + for _, ipS := range ips { + ip := net.ParseIP(ipS).To4() + if ip == nil { // To4 returns nil if IP is not a IPv4 address + log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS) + continue // one invalid IP address should not break the whole nameserver + } + ip4[fqdn] = []net.IP{ip} + } + } + return nil +} + +// listenAndServe starts a DNS server for the provided network and address. +func listenAndServe(net, addr string, shutdown chan os.Signal) { + s := &dns.Server{Addr: addr, Net: net} + go func() { + <-shutdown + log.Printf("shutting down server for %s", net) + s.Shutdown() + }() + log.Printf("listening for %s queries on %s", net, addr) + if err := s.ListenAndServe(); err != nil { + log.Fatalf("error running %s server: %v", net, err) + } +} + +// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap +// that's expected to be mounted at /config. Returns a channel that receives an +// event every time the contents get updated. +func ensureWatcherForKubeConfigMap(ctx context.Context) chan string { + c := make(chan string) + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v", err) + } + // kubelet mounts configmap to a Pod using a series of symlinks, one of + // which is /..data that Kubernetes recommends consumers to + // use if they need to monitor changes + // https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61 + toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn) + go func() { + defer watcher.Close() + log.Printf("starting file watch for %s", defaultDNSConfigDir) + for { + select { + case <-ctx.Done(): + log.Print("context cancelled, exiting ConfigMap watcher") + return + case event, ok := <-watcher.Events: + if !ok { + log.Fatal("watcher finished; exiting") + } + if event.Name == toWatch { + msg := fmt.Sprintf("ConfigMap update received: %s", event) + log.Print(msg) + c <- msg + } + case err, ok := <-watcher.Errors: + if err != nil { + // TODO (irbekrm): this runs in a + // container that will be thrown away, + // so this should be ok. But maybe still + // need to ensure that the DNS server + // terminates connections more + // gracefully. + log.Fatalf("[unexpected] error watching configuration: %v", err) + } + if !ok { + // TODO (irbekrm): this runs in a + // container that will be thrown away, + // so this should be ok. But maybe still + // need to ensure that the DNS server + // terminates connections more + // gracefully. + log.Fatalf("[unexpected] errors watcher exited") + } + } + } + }() + if err = watcher.Add(defaultDNSConfigDir); err != nil { + log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", err) + } + return c +} + +// configReaderFunc is a function that returns the desired nameserver configuration. +type configReaderFunc func() ([]byte, error) + +// configMapConfigReader reads the desired nameserver configuration from a +// dns.json file in a ConfigMap mounted at /config. +var configMapConfigReader configReaderFunc = func() ([]byte, error) { + if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, defaultDNSFile)); err == nil { + return contents, nil + } else if os.IsNotExist(err) { + return nil, nil + } else { + return nil, err + } +} + +// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's +// in-memory records. +func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP { + if n.ip4 == nil { + return nil + } + n.mu.Lock() + defer n.mu.Unlock() + f := n.ip4[fqdn] + return f +} diff --git a/cmd/k8s-nameserver/main_test.go b/cmd/k8s-nameserver/main_test.go new file mode 100644 index 000000000..2c0367e6e --- /dev/null +++ b/cmd/k8s-nameserver/main_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package main + +import ( + "net" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/miekg/dns" + "tailscale.com/util/dnsname" +) + +func TestNameserver(t *testing.T) { + + tests := []struct { + name string + ip4 map[dnsname.FQDN][]net.IP + query *dns.Msg + wantResp *dns.Msg + }{ + { + name: "A record query, record exists", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true}, + }, + wantResp: &dns.Msg{ + Answer: []dns.RR{&dns.A{Hdr: dns.RR_Header{ + Name: "foo.bar.com", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, + A: net.IP{1, 2, 3, 4}}}, + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeSuccess, + RecursionAvailable: false, + RecursionDesired: true, + Response: true, + Opcode: dns.OpcodeQuery, + Authoritative: true, + }}, + }, + { + name: "A record query, record does not exist", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{Id: 1}, + }, + wantResp: &dns.Msg{ + Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeNameError, + RecursionAvailable: false, + Response: true, + Opcode: dns.OpcodeQuery, + Authoritative: true, + }}, + }, + { + name: "A record query, but the name is not a valid FQDN", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{Id: 1}, + }, + wantResp: &dns.Msg{ + Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeFormatError, + Response: true, + Opcode: dns.OpcodeQuery, + }}, + }, + { + name: "AAAA record query", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, + MsgHdr: dns.MsgHdr{Id: 1}, + }, + wantResp: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeNotImplemented, + Response: true, + Opcode: dns.OpcodeQuery, + }}, + }, + { + name: "AAAA record query", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, + MsgHdr: dns.MsgHdr{Id: 1}, + }, + wantResp: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeNotImplemented, + Response: true, + Opcode: dns.OpcodeQuery, + }}, + }, + { + name: "CNAME record query", + ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}}, + query: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}}, + MsgHdr: dns.MsgHdr{Id: 1}, + }, + wantResp: &dns.Msg{ + Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}}, + MsgHdr: dns.MsgHdr{ + Id: 1, + Rcode: dns.RcodeNotImplemented, + Response: true, + Opcode: dns.OpcodeQuery, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ns := &nameserver{ + ip4: tt.ip4, + } + handler := ns.handleFunc() + fakeRespW := &fakeResponseWriter{} + handler(fakeRespW, tt.query) + if diff := cmp.Diff(*fakeRespW.msg, *tt.wantResp); diff != "" { + t.Fatalf("unexpected response (-got +want): \n%s", diff) + } + }) + } +} + +func TestResetRecords(t *testing.T) { + tests := []struct { + name string + config []byte + hasIp4 map[dnsname.FQDN][]net.IP + wantsIp4 map[dnsname.FQDN][]net.IP + wantsErr bool + }{ + { + name: "previously empty nameserver.ip4 gets set", + config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), + wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}}, + }, + { + name: "nameserver.ip4 gets reset", + hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, + config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), + wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}}, + }, + { + name: "configuration with incompatible version", + hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, + config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`), + wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, + wantsErr: true, + }, + { + name: "nameserver.ip4 gets reset to empty config when no configuration is provided", + hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, + wantsIp4: make(map[dnsname.FQDN][]net.IP), + }, + { + name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty", + hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}}, + config: []byte(`{"version": "v1alpha1", "ip4": {}}`), + wantsIp4: make(map[dnsname.FQDN][]net.IP), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ns := &nameserver{ + ip4: tt.hasIp4, + configReader: func() ([]byte, error) { return tt.config, nil }, + } + if err := ns.resetRecords(); err == nil == tt.wantsErr { + t.Errorf("resetRecords() returned err: %v, wantsErr: %v", err, tt.wantsErr) + } + if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" { + t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff) + } + }) + } +} + +// fakeResponseWriter is a faked out dns.ResponseWriter that can be used in +// tests that need to read the response message that was written. +type fakeResponseWriter struct { + msg *dns.Msg +} + +var _ dns.ResponseWriter = &fakeResponseWriter{} + +func (fr *fakeResponseWriter) WriteMsg(msg *dns.Msg) error { + fr.msg = msg + return nil +} +func (fr *fakeResponseWriter) LocalAddr() net.Addr { + return nil +} +func (fr *fakeResponseWriter) RemoteAddr() net.Addr { + return nil +} +func (fr *fakeResponseWriter) Write([]byte) (int, error) { + return 0, nil +} +func (fr *fakeResponseWriter) Close() error { + return nil +} +func (fr *fakeResponseWriter) TsigStatus() error { + return nil +} +func (fr *fakeResponseWriter) TsigTimersOnly(bool) {} +func (fr *fakeResponseWriter) Hijack() {} diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml index ff518e40c..0dc61f4f4 100644 --- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml +++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml @@ -24,6 +24,9 @@ rules: - apiGroups: ["tailscale.com"] resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"] verbs: ["get", "list", "watch", "update"] +- apiGroups: ["tailscale.com"] + resources: ["dnsconfigs", "dnsconfigs/status"] + verbs: ["get", "list", "watch", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -45,10 +48,10 @@ metadata: namespace: {{ .Release.Namespace }} rules: - apiGroups: [""] - resources: ["secrets"] + resources: ["secrets", "serviceaccounts", "configmaps"] verbs: ["*"] - apiGroups: ["apps"] - resources: ["statefulsets"] + resources: ["statefulsets", "deployments"] verbs: ["*"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml new file mode 100644 index 000000000..ba4a66c98 --- /dev/null +++ b/cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml @@ -0,0 +1,96 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: dnsconfigs.tailscale.com +spec: + group: tailscale.com + names: + kind: DNSConfig + listKind: DNSConfigList + plural: dnsconfigs + shortNames: + - dc + singular: dnsconfig + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Service IP address of the nameserver + jsonPath: .status.nameserverStatus.ip + name: NameserverIP + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + type: object + required: + - spec + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + type: object + required: + - nameserver + properties: + nameserver: + type: object + properties: + image: + type: object + properties: + repo: + type: string + tag: + type: string + status: + type: object + properties: + conditions: + type: array + items: + description: ConnectorCondition contains condition information for a Connector. + type: object + required: + - status + - type + properties: + lastTransitionTime: + description: LastTransitionTime is the timestamp corresponding to the last status change of this condition. + type: string + format: date-time + message: + description: Message is a human readable description of the details of the last transition, complementing reason. + type: string + observedGeneration: + description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector. + type: integer + format: int64 + reason: + description: Reason is a brief machine readable explanation for the condition's last transition. + type: string + status: + description: Status of the condition, one of ('True', 'False', 'Unknown'). + type: string + type: + description: Type of the condition, known values are (`SubnetRouterReady`). + type: string + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + nameserverStatus: + type: object + properties: + ip: + type: string + served: true + storage: true + subresources: + status: {} diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml new file mode 100644 index 000000000..febabb374 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: dnsconfig diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml new file mode 100644 index 000000000..735f59b79 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nameserver +spec: + replicas: 1 + revisionHistoryLimit: 5 + selector: + matchLabels: + app: nameserver + strategy: + type: Recreate + template: + metadata: + labels: + app: nameserver + spec: + containers: + - imagePullPolicy: IfNotPresent + name: nameserver + ports: + - name: tcp + protocol: TCP + containerPort: 1053 + - name: udp + protocol: UDP + containerPort: 1053 + volumeMounts: + - name: dnsconfig + mountPath: /config + restartPolicy: Always + serviceAccount: nameserver + serviceAccountName: nameserver + volumes: + - name: dnsconfig + configMap: + name: dnsconfig diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml new file mode 100644 index 000000000..b5a87b762 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nameserver +imagePullSecrets: +- name: foo diff --git a/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml b/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml new file mode 100644 index 000000000..08a90c176 --- /dev/null +++ b/cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: nameserver +spec: + selector: + app: nameserver + ports: + - name: udp + targetPort: 1053 + port: 53 + protocol: UDP + - name: tcp + targetPort: 1053 + port: 53 + protocol: TCP diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index af46e5a48..450144a4e 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/cmd/k8s-operator/deploy/manifests/operator.yaml @@ -159,6 +159,103 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: dnsconfigs.tailscale.com +spec: + group: tailscale.com + names: + kind: DNSConfig + listKind: DNSConfigList + plural: dnsconfigs + shortNames: + - dc + singular: dnsconfig + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Service IP address of the nameserver + jsonPath: .status.nameserverStatus.ip + name: NameserverIP + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + nameserver: + properties: + image: + properties: + repo: + type: string + tag: + type: string + type: object + type: object + required: + - nameserver + type: object + status: + properties: + conditions: + items: + description: ConnectorCondition contains condition information for a Connector. + properties: + lastTransitionTime: + description: LastTransitionTime is the timestamp corresponding to the last status change of this condition. + format: date-time + type: string + message: + description: Message is a human readable description of the details of the last transition, complementing reason. + type: string + observedGeneration: + description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector. + format: int64 + type: integer + reason: + description: Reason is a brief machine readable explanation for the condition's last transition. + type: string + status: + description: Status of the condition, one of ('True', 'False', 'Unknown'). + type: string + type: + description: Type of the condition, known values are (`SubnetRouterReady`). + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + nameserverStatus: + properties: + ip: + type: string + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.13.0 @@ -1252,6 +1349,16 @@ rules: - list - watch - update + - apiGroups: + - tailscale.com + resources: + - dnsconfigs + - dnsconfigs/status + verbs: + - get + - list + - watch + - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -1276,12 +1383,15 @@ rules: - "" resources: - secrets + - serviceaccounts + - configmaps verbs: - '*' - apiGroups: - apps resources: - statefulsets + - deployments verbs: - '*' --- diff --git a/cmd/k8s-operator/generate/main.go b/cmd/k8s-operator/generate/main.go index 7f2b18bf9..64fb7d991 100644 --- a/cmd/k8s-operator/generate/main.go +++ b/cmd/k8s-operator/generate/main.go @@ -22,9 +22,11 @@ const ( operatorDeploymentFilesPath = "cmd/k8s-operator/deploy" connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml" proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml" + dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml" helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates" connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml" proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml" + dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml" helmConditionalStart = "{{ if .Values.installCRDs -}}\n" helmConditionalEnd = "{{- end -}}" @@ -108,7 +110,7 @@ func main() { } } -// generate places tailscale.com CRDs (currently Connector and ProxyClass) into +// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into // the Helm chart templates behind .Values.installCRDs=true condition (true by // default). func generate(baseDir string) error { @@ -140,6 +142,9 @@ func generate(baseDir string) error { if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil { return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err) } + if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil { + return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err) + } return nil } @@ -151,5 +156,8 @@ func cleanup(baseDir string) error { if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err) } + if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err) + } return nil } diff --git a/cmd/k8s-operator/generate/main_test.go b/cmd/k8s-operator/generate/main_test.go index 7e309c1fd..febef6789 100644 --- a/cmd/k8s-operator/generate/main_test.go +++ b/cmd/k8s-operator/generate/main_test.go @@ -56,6 +56,9 @@ func Test_generate(t *testing.T) { if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") { t.Errorf("ProxyClass CRD not found in default chart install") } + if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") { + t.Errorf("DNSConfig CRD not found in default chart install") + } // Test that CRDs can be excluded from Helm chart install installContentsWithoutCRD := bytes.NewBuffer([]byte{}) @@ -71,4 +74,7 @@ func Test_generate(t *testing.T) { if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") { t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD") } + if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") { + t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD") + } } diff --git a/cmd/k8s-operator/nameserver.go b/cmd/k8s-operator/nameserver.go new file mode 100644 index 000000000..d0be65503 --- /dev/null +++ b/cmd/k8s-operator/nameserver.go @@ -0,0 +1,275 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package main + +import ( + "context" + "fmt" + "slices" + "sync" + + _ "embed" + + "github.com/pkg/errors" + "go.uber.org/zap" + xslices "golang.org/x/exp/slices" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/yaml" + tsoperator "tailscale.com/k8s-operator" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/tstime" + "tailscale.com/util/clientmetric" + "tailscale.com/util/set" +) + +const ( + reasonNameserverCreationFailed = "NameserverCreationFailed" + reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent" + + reasonNameserverCreated = "NameserverCreated" + + messageNameserverCreationFailed = "Failed creating nameserver resources: %v" + messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present." +) + +// NameserverReconciler knows how to create nameserver resources in cluster in +// response to users applying DNSConfig. +type NameserverReconciler struct { + client.Client + logger *zap.SugaredLogger + recorder record.EventRecorder + clock tstime.Clock + tsNamespace string + + mu sync.Mutex // protects following + managedNameservers set.Slice[types.UID] // one or none +} + +var ( + gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources") +) + +func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) { + logger := a.logger.With("dnsConfig", req.Name) + logger.Debugf("starting reconcile") + defer logger.Debugf("reconcile finished") + + var dnsCfg tsapi.DNSConfig + err = a.Get(ctx, req.NamespacedName, &dnsCfg) + if apierrors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + logger.Debugf("dnsconfig not found, assuming it was deleted") + return reconcile.Result{}, nil + } else if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err) + } + if !dnsCfg.DeletionTimestamp.IsZero() { + ix := xslices.Index(dnsCfg.Finalizers, FinalizerName) + if ix < 0 { + logger.Debugf("no finalizer, nothing to do") + return reconcile.Result{}, nil + } + logger.Info("Cleaning up DNSConfig resources") + if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil { + logger.Errorf("error cleaning up reconciler resource: %v", err) + return res, err + } + dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...) + if err := a.Update(ctx, &dnsCfg); err != nil { + logger.Errorf("error removing finalizer: %v", err) + return reconcile.Result{}, err + } + logger.Infof("Nameserver resources cleaned up") + return reconcile.Result{}, nil + } + + oldCnStatus := dnsCfg.Status.DeepCopy() + setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) { + tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger) + if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) { + // An error encountered here should get returned by the Reconcile function. + if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil { + err = errors.Wrap(err, updateErr.Error()) + } + } + return res, err + } + var dnsCfgs tsapi.DNSConfigList + if err := a.List(ctx, &dnsCfgs); err != nil { + return res, fmt.Errorf("error listing DNSConfigs: %w", err) + } + if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton + msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created." + logger.Error(msg) + a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent) + setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent) + } + + if !slices.Contains(dnsCfg.Finalizers, FinalizerName) { + logger.Infof("ensuring nameserver resources") + dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName) + if err := a.Update(ctx, &dnsCfg); err != nil { + msg := fmt.Sprintf(messageNameserverCreationFailed, err) + logger.Error(msg) + return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg) + } + } + if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil { + return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err) + } + + a.mu.Lock() + a.managedNameservers.Add(dnsCfg.UID) + a.mu.Unlock() + gaugeNameserverResources.Set(int64(a.managedNameservers.Len())) + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace}, + } + if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil { + return res, fmt.Errorf("error getting Service: %w", err) + } + if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" { + dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{ + IP: ip, + } + return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated) + } + logger.Info("nameserver Service does not have an IP address allocated, waiting...") + return reconcile.Result{}, nil +} + +func nameserverResourceLabels(name, namespace string) map[string]string { + labels := childResourceLabels(name, namespace, "nameserver") + labels["app.kubernetes.io/name"] = "tailscale" + labels["app.kubernetes.io/component"] = "nameserver" + return labels +} + +func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error { + labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace) + dCfg := &deployConfig{ + ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))}, + namespace: a.tsNamespace, + labels: labels, + } + if tsDNSCfg.Spec.Nameserver.Image.Repo != "" { + dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo + } + if tsDNSCfg.Spec.Nameserver.Image.Tag != "" { + dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag + } + for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} { + if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil { + return fmt.Errorf("error reconciling %s: %w", deployable.kind, err) + } + } + return nil +} + +// maybeCleanup removes DNSConfig from being tracked. The cluster resources +// created, will be automatically garbage collected as they are owned by the +// DNSConfig. +func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error { + a.mu.Lock() + a.managedNameservers.Remove(dnsCfg.UID) + a.mu.Unlock() + gaugeNameserverResources.Set(int64(a.managedNameservers.Len())) + return nil +} + +type deployable struct { + kind string + updateObj func(context.Context, *deployConfig, client.Client) error +} + +type deployConfig struct { + imageRepo string + imageTag string + labels map[string]string + ownerRefs []metav1.OwnerReference + namespace string +} + +var ( + //go:embed deploy/manifests/nameserver/cm.yaml + cmYaml []byte + //go:embed deploy/manifests/nameserver/deploy.yaml + deployYaml []byte + //go:embed deploy/manifests/nameserver/sa.yaml + saYaml []byte + //go:embed deploy/manifests/nameserver/svc.yaml + svcYaml []byte + + deployDeployable = deployable{ + kind: "Deployment", + updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { + d := new(appsv1.Deployment) + if err := yaml.Unmarshal(deployYaml, &d); err != nil { + return fmt.Errorf("error unmarshalling Deployment yaml: %w", err) + } + d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag) + d.ObjectMeta.Namespace = cfg.namespace + d.ObjectMeta.Labels = cfg.labels + d.ObjectMeta.OwnerReferences = cfg.ownerRefs + updateF := func(oldD *appsv1.Deployment) { + oldD.Spec = d.Spec + } + _, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF) + return err + }, + } + saDeployable = deployable{ + kind: "ServiceAccount", + updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { + sa := new(corev1.ServiceAccount) + if err := yaml.Unmarshal(saYaml, &sa); err != nil { + return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err) + } + sa.ObjectMeta.Labels = cfg.labels + sa.ObjectMeta.OwnerReferences = cfg.ownerRefs + sa.ObjectMeta.Namespace = cfg.namespace + _, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {}) + return err + }, + } + svcDeployable = deployable{ + kind: "Service", + updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { + svc := new(corev1.Service) + if err := yaml.Unmarshal(svcYaml, &svc); err != nil { + return fmt.Errorf("error unmarshalling Service yaml: %w", err) + } + svc.ObjectMeta.Labels = cfg.labels + svc.ObjectMeta.OwnerReferences = cfg.ownerRefs + svc.ObjectMeta.Namespace = cfg.namespace + _, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {}) + return err + }, + } + cmDeployable = deployable{ + kind: "ConfigMap", + updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error { + cm := new(corev1.ConfigMap) + if err := yaml.Unmarshal(cmYaml, &cm); err != nil { + return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err) + } + cm.ObjectMeta.Labels = cfg.labels + cm.ObjectMeta.OwnerReferences = cfg.ownerRefs + cm.ObjectMeta.Namespace = cfg.namespace + _, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {}) + return err + }, + } +) diff --git a/cmd/k8s-operator/nameserver_test.go b/cmd/k8s-operator/nameserver_test.go new file mode 100644 index 000000000..89f2b25d4 --- /dev/null +++ b/cmd/k8s-operator/nameserver_test.go @@ -0,0 +1,118 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +// tailscale-operator provides a way to expose services running in a Kubernetes +// cluster to your Tailnet and to make Tailscale nodes available to cluster +// workloads +package main + +import ( + "encoding/json" + "testing" + "time" + + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" + operatorutils "tailscale.com/k8s-operator" + tsapi "tailscale.com/k8s-operator/apis/v1alpha1" + "tailscale.com/tstest" + "tailscale.com/util/mak" +) + +func TestNameserverReconciler(t *testing.T) { + dnsCfg := &tsapi.DNSConfig{ + TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: tsapi.DNSConfigSpec{ + Nameserver: &tsapi.Nameserver{ + Image: &tsapi.Image{ + Repo: "test", + Tag: "v0.0.1", + }, + }, + }, + } + + fc := fake.NewClientBuilder(). + WithScheme(tsapi.GlobalScheme). + WithObjects(dnsCfg). + WithStatusSubresource(dnsCfg). + Build() + zl, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + cl := tstest.NewClock(tstest.ClockOpts{}) + nr := &NameserverReconciler{ + Client: fc, + clock: cl, + logger: zl.Sugar(), + tsNamespace: "tailscale", + } + expectReconciled(t, nr, "", "test") + // Verify that nameserver Deployment has been created and has the expected fields. + wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}} + if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil { + t.Fatalf("unmarshalling yaml: %v", err) + } + dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig")) + wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef} + wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1" + wantsDeploy.Namespace = "tailscale" + labels := nameserverResourceLabels("test", "tailscale") + wantsDeploy.ObjectMeta.Labels = labels + expectEqual(t, fc, wantsDeploy, nil) + + // Verify that DNSConfig advertizes the nameserver's Service IP address, + // has the ready status condition and tailscale finalizer. + mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) { + svc.Spec.ClusterIP = "1.2.3.4" + }) + expectReconciled(t, nr, "", "test") + dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{ + IP: "1.2.3.4", + } + dnsCfg.Finalizers = []string{FinalizerName} + dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{ + Type: tsapi.NameserverReady, + Status: metav1.ConditionTrue, + Reason: reasonNameserverCreated, + Message: reasonNameserverCreated, + LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)}, + }) + expectEqual(t, fc, dnsCfg, nil) + + // // Verify that nameserver image gets updated to match DNSConfig spec. + mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) { + dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2" + }) + expectReconciled(t, nr, "", "test") + wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2" + expectEqual(t, fc, wantsDeploy, nil) + + // Verify that when another actor sets ConfigMap data, it does not get + // overwritten by nameserver reconciler. + dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}} + bs, err := json.Marshal(dnsRecords) + if err != nil { + t.Fatalf("error marshalling ConfigMap contents: %v", err) + } + mustUpdate(t, fc, "tailscale", "dnsconfig", func(cm *corev1.ConfigMap) { + mak.Set(&cm.Data, "dns.json", string(bs)) + }) + expectReconciled(t, nr, "", "test") + wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsconfig", + Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}}, + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + Data: map[string]string{"dns.json": string(bs)}, + } + expectEqual(t, fc, wantCm, nil) +} diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index cf969372c..cbec32eb3 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -223,8 +223,11 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string // resources that we GET via the controller manager's client. Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ - &corev1.Secret{}: nsFilter, - &appsv1.StatefulSet{}: nsFilter, + &corev1.Secret{}: nsFilter, + &corev1.ServiceAccount{}: nsFilter, + &corev1.ConfigMap{}: nsFilter, + &appsv1.StatefulSet{}: nsFilter, + &appsv1.Deployment{}: nsFilter, }, }, Scheme: tsapi.GlobalScheme, @@ -309,7 +312,28 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string clock: tstime.DefaultClock{}, }) if err != nil { - startlog.Fatal("could not create connector reconciler: %v", err) + startlog.Fatalf("could not create connector reconciler: %v", err) + } + // TODO (irbekrm): switch to metadata-only watches for resources whose + // spec we don't need to inspect to reduce memory consumption + // https://github.com/kubernetes-sigs/controller-runtime/issues/1159 + nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver")) + err = builder.ControllerManagedBy(mgr). + For(&tsapi.DNSConfig{}). + Watches(&appsv1.Deployment{}, nameserverFilter). + Watches(&corev1.ConfigMap{}, nameserverFilter). + Watches(&corev1.Service{}, nameserverFilter). + Watches(&corev1.ServiceAccount{}, nameserverFilter). + Complete(&NameserverReconciler{ + recorder: eventRecorder, + tsNamespace: tsNamespace, + + Client: mgr.GetClient(), + logger: zlog.Named("nameserver-reconciler"), + clock: tstime.DefaultClock{}, + }) + if err != nil { + startlog.Fatalf("could not create nameserver reconciler: %v", err) } err = builder.ControllerManagedBy(mgr). For(&tsapi.ProxyClass{}). diff --git a/cmd/k8s-operator/operator_test.go b/cmd/k8s-operator/operator_test.go index aa365fd11..6556b0250 100644 --- a/cmd/k8s-operator/operator_test.go +++ b/cmd/k8s-operator/operator_test.go @@ -1196,7 +1196,6 @@ func TestTailscaledConfigfileHash(t *testing.T) { expectReconciled(t, sr, "default", "test") expectEqual(t, fc, expectedSTS(t, fc, o), nil) } - func Test_isMagicDNSName(t *testing.T) { tests := []struct { in string diff --git a/cmd/k8s-operator/proxyclass.go b/cmd/k8s-operator/proxyclass.go index 0cb653477..a76a67a89 100644 --- a/cmd/k8s-operator/proxyclass.go +++ b/cmd/k8s-operator/proxyclass.go @@ -3,8 +3,6 @@ //go:build !plan9 -// tailscale-operator provides a way to expose services running in a Kubernetes -// cluster to your Tailnet. package main import ( diff --git a/k8s-operator/api.md b/k8s-operator/api.md index 6b254351e..1e7c9879b 100644 --- a/k8s-operator/api.md +++ b/k8s-operator/api.md @@ -10,6 +10,8 @@ Resource Types: - [Connector](#connector) +- [DNSConfig](#dnsconfig) + - [ProxyClass](#proxyclass) @@ -259,6 +261,274 @@ ConnectorCondition contains condition information for a Connector. +## DNSConfig +[↩ Parent](#tailscalecomv1alpha1 ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringtailscale.com/v1alpha1true
kindstringDNSConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject +
+
true
statusobject +
+
false
+ + +### DNSConfig.spec +[↩ Parent](#dnsconfig) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
nameserverobject +
+
true
+ + +### DNSConfig.spec.nameserver +[↩ Parent](#dnsconfigspec) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
imageobject +
+
false
+ + +### DNSConfig.spec.nameserver.image +[↩ Parent](#dnsconfigspecnameserver) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
repostring +
+
false
tagstring +
+
false
+ + +### DNSConfig.status +[↩ Parent](#dnsconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object +
+
false
nameserverStatusobject +
+
false
+ + +### DNSConfig.status.conditions[index] +[↩ Parent](#dnsconfigstatus) + + + +ConnectorCondition contains condition information for a Connector. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
statusstring + Status of the condition, one of ('True', 'False', 'Unknown').
+
true
typestring + Type of the condition, known values are (`SubnetRouterReady`).
+
true
lastTransitionTimestring + LastTransitionTime is the timestamp corresponding to the last status change of this condition.
+
+ Format: date-time
+
false
messagestring + Message is a human readable description of the details of the last transition, complementing reason.
+
false
observedGenerationinteger + If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
+
+ Format: int64
+
false
reasonstring + Reason is a brief machine readable explanation for the condition's last transition.
+
false
+ + +### DNSConfig.status.nameserverStatus +[↩ Parent](#dnsconfigstatus) + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
ipstring +
+
false
+ ## ProxyClass [↩ Parent](#tailscalecomv1alpha1 ) diff --git a/k8s-operator/apis/v1alpha1/register.go b/k8s-operator/apis/v1alpha1/register.go index 4e6bfda64..8c888ff05 100644 --- a/k8s-operator/apis/v1alpha1/register.go +++ b/k8s-operator/apis/v1alpha1/register.go @@ -49,7 +49,7 @@ func init() { // Adds the list of known types to api.Scheme. func addKnownTypes(scheme *runtime.Scheme) error { - scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{}, &ProxyClass{}, &ProxyClassList{}) + scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{}, &ProxyClass{}, &ProxyClassList{}, &DNSConfig{}, &DNSConfigList{}) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go b/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go new file mode 100644 index 000000000..7104d3c13 --- /dev/null +++ b/k8s-operator/apis/v1alpha1/types_tsdnsconfig.go @@ -0,0 +1,71 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Code comments on these types should be treated as user facing documentation- +// they will appear on the DNSConfig CRD i.e if someone runs kubectl explain dnsconfig. + +var DNSConfigKind = "DNSConfig" + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=dc +// +kubebuilder:printcolumn:name="NameserverIP",type="string",JSONPath=`.status.nameserverStatus.ip`,description="Service IP address of the nameserver" + +type DNSConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DNSConfigSpec `json:"spec"` + + // +optional + Status DNSConfigStatus `json:"status"` +} + +// +kubebuilder:object:root=true + +type DNSConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []DNSConfig `json:"items"` +} + +type DNSConfigSpec struct { + Nameserver *Nameserver `json:"nameserver"` +} + +type Nameserver struct { + // +optional + Image *Image `json:"image,omitempty"` +} + +type Image struct { + // +optional + Repo string `json:"repo,omitempty"` + // +optional + Tag string `json:"tag,omitempty"` +} + +type DNSConfigStatus struct { + // +listType=map + // +listMapKey=type + // +optional + Conditions []ConnectorCondition `json:"conditions"` + // +optional + NameserverStatus *NameserverStatus `json:"nameserverStatus"` +} + +type NameserverStatus struct { + // +optional + IP string `json:"ip"` +} + +const NameserverReady ConnectorConditionType = `NameserverReady` diff --git a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go index 4893f52e0..419a763fd 100644 --- a/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go +++ b/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go @@ -163,6 +163,112 @@ func (in *Container) DeepCopy() *Container { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfig) DeepCopyInto(out *DNSConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfig. +func (in *DNSConfig) DeepCopy() *DNSConfig { + if in == nil { + return nil + } + out := new(DNSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigList) DeepCopyInto(out *DNSConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigList. +func (in *DNSConfigList) DeepCopy() *DNSConfigList { + if in == nil { + return nil + } + out := new(DNSConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigSpec) DeepCopyInto(out *DNSConfigSpec) { + *out = *in + if in.Nameserver != nil { + in, out := &in.Nameserver, &out.Nameserver + *out = new(Nameserver) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigSpec. +func (in *DNSConfigSpec) DeepCopy() *DNSConfigSpec { + if in == nil { + return nil + } + out := new(DNSConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSConfigStatus) DeepCopyInto(out *DNSConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]ConnectorCondition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.NameserverStatus != nil { + in, out := &in.NameserverStatus, &out.NameserverStatus + *out = new(NameserverStatus) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigStatus. +func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus { + if in == nil { + return nil + } + out := new(DNSConfigStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Env) DeepCopyInto(out *Env) { *out = *in @@ -178,6 +284,21 @@ func (in *Env) DeepCopy() *Env { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Image) DeepCopyInto(out *Image) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Image. +func (in *Image) DeepCopy() *Image { + if in == nil { + return nil + } + out := new(Image) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Metrics) DeepCopyInto(out *Metrics) { *out = *in @@ -193,6 +314,41 @@ func (in *Metrics) DeepCopy() *Metrics { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Nameserver) DeepCopyInto(out *Nameserver) { + *out = *in + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(Image) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Nameserver. +func (in *Nameserver) DeepCopy() *Nameserver { + if in == nil { + return nil + } + out := new(Nameserver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NameserverStatus) DeepCopyInto(out *NameserverStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameserverStatus. +func (in *NameserverStatus) DeepCopy() *NameserverStatus { + if in == nil { + return nil + } + out := new(NameserverStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Pod) DeepCopyInto(out *Pod) { *out = *in diff --git a/k8s-operator/conditions.go b/k8s-operator/conditions.go index 146487fb8..cdcdbe770 100644 --- a/k8s-operator/conditions.go +++ b/k8s-operator/conditions.go @@ -24,7 +24,7 @@ func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConnectorCon cn.Status.Conditions = conds } -// RemoveConnectorCondition will remove condition of the given type. +// RemoveConnectorCondition will remove condition of the given type if it exists. func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConnectorConditionType) { conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond tsapi.ConnectorCondition) bool { return cond.Type == conditionType @@ -39,6 +39,14 @@ func SetProxyClassCondition(pc *tsapi.ProxyClass, conditionType tsapi.ConnectorC pc.Status.Conditions = conds } +// SetDNSConfigCondition ensures that DNSConfig status has a condition with the +// given attributes. LastTransitionTime gets set every time condition's status +// changes +func SetDNSConfigCondition(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) { + conds := updateCondition(dnsCfg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger) + dnsCfg.Status.Conditions = conds +} + func updateCondition(conds []tsapi.ConnectorCondition, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []tsapi.ConnectorCondition { newCondition := tsapi.ConnectorCondition{ Type: conditionType, @@ -61,8 +69,9 @@ func updateCondition(conds []tsapi.ConnectorCondition, conditionType tsapi.Conne } cond := conds[idx] // update the existing condition - // If this update doesn't contain a state transition, we don't update - // the conditions LastTransitionTime to Now(). + + // If this update doesn't contain a state transition, don't update last + // transition time. if cond.Status == status { newCondition.LastTransitionTime = cond.LastTransitionTime } else { @@ -82,3 +91,14 @@ func ProxyClassIsReady(pc *tsapi.ProxyClass) bool { cond := pc.Status.Conditions[idx] return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == pc.Generation } + +func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool { + idx := xslices.IndexFunc(cfg.Status.Conditions, func(cond tsapi.ConnectorCondition) bool { + return cond.Type == tsapi.NameserverReady + }) + if idx == -1 { + return false + } + cond := cfg.Status.Conditions[idx] + return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation +} diff --git a/k8s-operator/conditions_test.go b/k8s-operator/conditions_test.go index 624891d75..43fea9cb7 100644 --- a/k8s-operator/conditions_test.go +++ b/k8s-operator/conditions_test.go @@ -98,5 +98,4 @@ func TestSetConnectorCondition(t *testing.T) { }, }, }) - } diff --git a/k8s-operator/tsdns.go b/k8s-operator/tsdns.go new file mode 100644 index 000000000..79e77a561 --- /dev/null +++ b/k8s-operator/tsdns.go @@ -0,0 +1,17 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !plan9 + +package kube + +const Alpha1Version = "v1alpha1" + +type Records struct { + // Version is the version of this Records configuration. Version is + // intended to be used by ./cmd/k8s-nameserver to determine whether it + // can read this records configuration. + Version string `json:"version"` + // IP4 contains a mapping of DNS names to IPv4 address(es). + IP4 map[string][]string `json:"ip4"` +}