Revert "cmd/{k8s-nameserver,k8s-operator},k8s-operator: add a kube nameserver, make operator deploy it (#11017)" (#11669)
Temporarily reverting this PR to avoid releasing
half finished featue.
This reverts commit 9e2f58f846.
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
main
parent
0001237253
commit
231e44e742
@ -1,348 +0,0 @@ |
||||
// 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\n", 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\n") |
||||
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): what TTL?
|
||||
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
|
||||
fallthrough |
||||
default: |
||||
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s\n", 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...\n") |
||||
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
||||
log.Fatalf("error setting nameserver's records: %v\n", err) |
||||
} |
||||
log.Print("nameserver's records were updated\n") |
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
log.Printf("context cancelled, exiting records reconciler\n") |
||||
return |
||||
case <-n.configWatcher: |
||||
log.Print("configuration update detected, resetting records\n") |
||||
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\n", err) |
||||
} |
||||
log.Print("nameserver records were reset\n") |
||||
} |
||||
} |
||||
}() |
||||
} |
||||
|
||||
// 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\n", err) |
||||
return err |
||||
} |
||||
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 { |
||||
log.Print("nameserver's configuration is empty, any in-memory records will be unset\n") |
||||
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 dnsCfg.IP4 == nil || len(dnsCfg.IP4) == 0 { |
||||
log.Print("nameserver's configuration contains no records, any in-memory records will be unset\n") |
||||
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\n", 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\n", 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\n", net) |
||||
s.Shutdown() |
||||
}() |
||||
log.Printf("listening for %s queries on %s\n", net, addr) |
||||
if err := s.ListenAndServe(); err != nil { |
||||
log.Fatalf("error running %s server: %v\n", 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\n", err) |
||||
} |
||||
// kubelet mounts configmap to a Pod using a series of symlinks, one of
|
||||
// which is <mount-dir>/..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\n", defaultDNSConfigDir) |
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
log.Print("context cancelled, exiting ConfigMap watcher\n") |
||||
return |
||||
case event, ok := <-watcher.Events: |
||||
if !ok { |
||||
log.Fatal("watcher finished; exiting") |
||||
} |
||||
if event.Name == toWatch { |
||||
msg := fmt.Sprintf("ConfigMap update received: %s\n", event) |
||||
log.Print(msg) |
||||
c <- msg |
||||
} |
||||
case err, ok := <-watcher.Errors: |
||||
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] configuration watcher error: errors watcher finished: %v\n", err) |
||||
} |
||||
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\n", err) |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
if err = watcher.Add(defaultDNSConfigDir); err != nil { |
||||
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v\n", 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 |
||||
} |
||||
@ -1,227 +0,0 @@ |
||||
// 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() {} |
||||
@ -1,96 +0,0 @@ |
||||
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: {} |
||||
@ -1,4 +0,0 @@ |
||||
apiVersion: v1 |
||||
kind: ConfigMap |
||||
metadata: |
||||
name: dnsconfig |
||||
@ -1,37 +0,0 @@ |
||||
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 |
||||
@ -1,6 +0,0 @@ |
||||
apiVersion: v1 |
||||
kind: ServiceAccount |
||||
metadata: |
||||
name: nameserver |
||||
imagePullSecrets: |
||||
- name: foo |
||||
@ -1,16 +0,0 @@ |
||||
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 |
||||
@ -1,278 +0,0 @@ |
||||
// 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 ( |
||||
"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 |
||||
}, |
||||
} |
||||
) |
||||
@ -1,118 +0,0 @@ |
||||
// 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) |
||||
} |
||||
@ -1,71 +0,0 @@ |
||||
// 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` |
||||
@ -1,17 +0,0 @@ |
||||
// 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"` |
||||
} |
||||
Loading…
Reference in new issue