diff options
Diffstat (limited to 'plugin/federation')
-rw-r--r-- | plugin/federation/README.md | 43 | ||||
-rw-r--r-- | plugin/federation/federation.go | 141 | ||||
-rw-r--r-- | plugin/federation/federation_test.go | 81 | ||||
-rw-r--r-- | plugin/federation/kubernetes_api_test.go | 111 | ||||
-rw-r--r-- | plugin/federation/setup.go | 89 | ||||
-rw-r--r-- | plugin/federation/setup_test.go | 65 |
6 files changed, 530 insertions, 0 deletions
diff --git a/plugin/federation/README.md b/plugin/federation/README.md new file mode 100644 index 000000000..fb3d44e8c --- /dev/null +++ b/plugin/federation/README.md @@ -0,0 +1,43 @@ +# federation + +The *federation* plugin enables +[federated](https://kubernetes.io/docs/tasks/federation/federation-service-discovery/) queries to be +resolved via the kubernetes plugin. + +Enabling *federation* without also having *kubernetes* is a noop. + +## Syntax + +~~~ +federation [ZONES...] { + NAME DOMAIN +~~~ + +* Each **NAME** and **DOMAIN** defines federation membership. One entry for each. A duplicate + **NAME** will silently overwrite any previous value. + +## Examples + +Here we handle all service requests in the `prod` and `stage` federations. + +~~~ txt +. { + kubernetes cluster.local + federation cluster.local { + prod prod.feddomain.com + staging staging.feddomain.com + } +} +~~~ + +Or slightly shorter: + +~~~ txt +cluster.local { + kubernetes + federation { + prod prod.feddomain.com + staging staging.feddomain.com + } +} +~~~ diff --git a/plugin/federation/federation.go b/plugin/federation/federation.go new file mode 100644 index 000000000..c94e8f819 --- /dev/null +++ b/plugin/federation/federation.go @@ -0,0 +1,141 @@ +/* +Package federation implements kubernetes federation. It checks if the qname matches +a possible federation. If this is the case and the captured answer is an NXDOMAIN, +federation is performed. If this is not the case the original answer is returned. + +The federation label is always the 2nd to last once the zone is chopped of. For +instance "nginx.mynamespace.myfederation.svc.example.com" has "myfederation" as +the federation label. For federation to work we do a normal k8s lookup +*without* that label, if that comes back with NXDOMAIN or NODATA(??) we create +a federation record and return that. + +Federation is only useful in conjunction with the kubernetes plugin, without it is a noop. +*/ +package federation + +import ( + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/etcd/msg" + "github.com/coredns/coredns/plugin/pkg/dnsutil" + "github.com/coredns/coredns/plugin/pkg/nonwriter" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// Federation contains the name to zone mapping used for federation in kubernetes. +type Federation struct { + f map[string]string + zones []string + + Next plugin.Handler + Federations Func +} + +// Func needs to be implemented by any plugin that implements +// federation. Right now this is only the kubernetes plugin. +type Func func(state request.Request, fname, fzone string) (msg.Service, error) + +// New returns a new federation. +func New() *Federation { + return &Federation{f: make(map[string]string)} +} + +// ServeDNS implements the plugin.Handle interface. +func (f *Federation) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if f.Federations == nil { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + state := request.Request{W: w, Req: r} + zone := plugin.Zones(f.zones).Matches(state.Name()) + if zone == "" { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + state.Zone = zone + + // Remove the federation label from the qname to see if something exists. + without, label := f.isNameFederation(state.Name(), state.Zone) + if without == "" { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + + qname := r.Question[0].Name + r.Question[0].Name = without + state.Clear() + + // Start the next plugin, but with a nowriter, capture the result, if NXDOMAIN + // perform federation, otherwise just write the result. + nw := nonwriter.New(w) + ret, err := plugin.NextOrFailure(f.Name(), f.Next, ctx, nw, r) + + if !plugin.ClientWrite(ret) { + // something went wrong + r.Question[0].Name = qname + return ret, err + } + + if m := nw.Msg; m.Rcode != dns.RcodeNameError { + // If positive answer we need to substitute the original qname in the answer. + m.Question[0].Name = qname + for _, a := range m.Answer { + a.Header().Name = qname + } + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + + return dns.RcodeSuccess, nil + } + + // Still here, we've seen NXDOMAIN and need to perform federation. + service, err := f.Federations(state, label, f.f[label]) // state references Req which has updated qname + if err != nil { + r.Question[0].Name = qname + return dns.RcodeServerFailure, err + } + + r.Question[0].Name = qname + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative, m.RecursionAvailable, m.Compress = true, true, true + + m.Answer = []dns.RR{service.NewCNAME(state.QName(), service.Host)} + + state.SizeAndDo(m) + m, _ = state.Scrub(m) + w.WriteMsg(m) + + return dns.RcodeSuccess, nil +} + +// Name implements the plugin.Handle interface. +func (f *Federation) Name() string { return "federation" } + +// IsNameFederation checks the qname to see if it is a potential federation. The federation +// label is always the 2nd to last once the zone is chopped of. For instance +// "nginx.mynamespace.myfederation.svc.example.com" has "myfederation" as the federation label. +// IsNameFederation returns a new qname with the federation label and the label itself or two +// empty strings if there wasn't a hit. +func (f *Federation) isNameFederation(name, zone string) (string, string) { + base, _ := dnsutil.TrimZone(name, zone) + + // TODO(miek): dns.PrevLabel is better for memory, or dns.Split. + labels := dns.SplitDomainName(base) + ll := len(labels) + if ll < 2 { + return "", "" + } + + fed := labels[ll-2] + + if _, ok := f.f[fed]; ok { + without := dnsutil.Join(labels[:ll-2]) + labels[ll-1] + "." + zone + return without, fed + } + return "", "" +} diff --git a/plugin/federation/federation_test.go b/plugin/federation/federation_test.go new file mode 100644 index 000000000..920f1a340 --- /dev/null +++ b/plugin/federation/federation_test.go @@ -0,0 +1,81 @@ +package federation + +import ( + "testing" + + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/coredns/coredns/plugin/pkg/dnsrecorder" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +func TestIsNameFederation(t *testing.T) { + tests := []struct { + fed string + qname string + expectedZone string + }{ + {"prod", "nginx.mynamespace.prod.svc.example.com.", "nginx.mynamespace.svc.example.com."}, + {"prod", "nginx.mynamespace.staging.svc.example.com.", ""}, + {"prod", "nginx.mynamespace.example.com.", ""}, + {"prod", "example.com.", ""}, + {"prod", "com.", ""}, + } + + fed := New() + for i, tc := range tests { + fed.f[tc.fed] = "test-name" + if x, _ := fed.isNameFederation(tc.qname, "example.com."); x != tc.expectedZone { + t.Errorf("Test %d, failed to get zone, expected %s, got %s", i, tc.expectedZone, x) + } + } +} + +func TestFederationKubernetes(t *testing.T) { + tests := []test.Case{ + { + // service exists so we return the IP address associated with it. + Qname: "svc1.testns.prod.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.A("svc1.testns.prod.svc.cluster.local. 303 IN A 10.0.0.1"), + }, + }, + { + // service does not exist, do the federation dance. + Qname: "svc0.testns.prod.svc.cluster.local.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + Answer: []dns.RR{ + test.CNAME("svc0.testns.prod.svc.cluster.local. 303 IN CNAME svc0.testns.prod.svc.fd-az.fd-r.federal.example."), + }, + }, + } + + k := kubernetes.New([]string{"cluster.local."}) + k.APIConn = &APIConnFederationTest{} + + fed := New() + fed.zones = []string{"cluster.local."} + fed.Federations = k.Federations + fed.Next = k + fed.f = map[string]string{ + "prod": "federal.example.", + } + + ctx := context.TODO() + for i, tc := range tests { + m := tc.Msg() + + rec := dnsrecorder.New(&test.ResponseWriter{}) + _, err := fed.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Test %d, expected no error, got %v\n", i, err) + return + } + + resp := rec.Msg + test.SortAndCheck(t, resp, tc) + } +} diff --git a/plugin/federation/kubernetes_api_test.go b/plugin/federation/kubernetes_api_test.go new file mode 100644 index 000000000..48a03666e --- /dev/null +++ b/plugin/federation/kubernetes_api_test.go @@ -0,0 +1,111 @@ +package federation + +import ( + "github.com/coredns/coredns/plugin/kubernetes" + + "k8s.io/client-go/1.5/pkg/api" +) + +type APIConnFederationTest struct{} + +func (APIConnFederationTest) Run() { return } +func (APIConnFederationTest) Stop() error { return nil } + +func (APIConnFederationTest) PodIndex(string) []interface{} { + a := make([]interface{}, 1) + a[0] = &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Namespace: "podns", + }, + Status: api.PodStatus{ + PodIP: "10.240.0.1", // Remote IP set in test.ResponseWriter + }, + } + return a +} + +func (APIConnFederationTest) ServiceList() []*api.Service { + svcs := []*api.Service{ + { + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "hdls1", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ClusterIP: api.ClusterIPNone, + }, + }, + { + ObjectMeta: api.ObjectMeta{ + Name: "external", + Namespace: "testns", + }, + Spec: api.ServiceSpec{ + ExternalName: "ext.interwebs.test", + Ports: []api.ServicePort{{ + Name: "http", + Protocol: "tcp", + Port: 80, + }}, + }, + }, + } + return svcs + +} + +func (APIConnFederationTest) EndpointsList() api.EndpointsList { + return api.EndpointsList{ + Items: []api.Endpoints{ + { + Subsets: []api.EndpointSubset{ + { + Addresses: []api.EndpointAddress{ + { + IP: "172.0.0.1", + Hostname: "ep1a", + }, + }, + Ports: []api.EndpointPort{ + { + Port: 80, + Protocol: "tcp", + Name: "http", + }, + }, + }, + }, + ObjectMeta: api.ObjectMeta{ + Name: "svc1", + Namespace: "testns", + }, + }, + }, + } +} + +func (APIConnFederationTest) GetNodeByName(name string) (api.Node, error) { + return api.Node{ + ObjectMeta: api.ObjectMeta{ + Name: "test.node.foo.bar", + Labels: map[string]string{ + kubernetes.LabelRegion: "fd-r", + kubernetes.LabelZone: "fd-az", + }, + }, + }, nil +} diff --git a/plugin/federation/setup.go b/plugin/federation/setup.go new file mode 100644 index 000000000..72514fe8f --- /dev/null +++ b/plugin/federation/setup.go @@ -0,0 +1,89 @@ +package federation + +import ( + "fmt" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/kubernetes" + "github.com/miekg/dns" + + "github.com/mholt/caddy" +) + +func init() { + caddy.RegisterPlugin("federation", caddy.Plugin{ + ServerType: "dns", + Action: setup, + }) +} + +func setup(c *caddy.Controller) error { + fed, err := federationParse(c) + if err != nil { + return plugin.Error("federation", err) + } + + // Do this in OnStartup, so all plugin has been initialized. + c.OnStartup(func() error { + m := dnsserver.GetConfig(c).Handler("kubernetes") + if m == nil { + return nil + } + if x, ok := m.(*kubernetes.Kubernetes); ok { + fed.Federations = x.Federations + } + return nil + }) + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + fed.Next = next + return fed + }) + + return nil +} + +func federationParse(c *caddy.Controller) (*Federation, error) { + fed := New() + + for c.Next() { + // federation [zones..] + zones := c.RemainingArgs() + origins := []string{} + if len(zones) > 0 { + origins = make([]string, len(zones)) + copy(origins, zones) + } else { + origins = make([]string, len(c.ServerBlockKeys)) + copy(origins, c.ServerBlockKeys) + } + + for c.NextBlock() { + x := c.Val() + switch x { + default: + args := c.RemainingArgs() + if x := len(args); x != 1 { + return fed, fmt.Errorf("need two arguments for federation, got %d", x) + } + + fed.f[x] = dns.Fqdn(args[0]) + } + } + + for i := range origins { + origins[i] = plugin.Host(origins[i]).Normalize() + } + + fed.zones = origins + + if len(fed.f) == 0 { + return fed, fmt.Errorf("at least one name to zone federation expected") + } + + return fed, nil + } + + return fed, nil +} diff --git a/plugin/federation/setup_test.go b/plugin/federation/setup_test.go new file mode 100644 index 000000000..e85b01772 --- /dev/null +++ b/plugin/federation/setup_test.go @@ -0,0 +1,65 @@ +package federation + +import ( + "testing" + + "github.com/mholt/caddy" +) + +func TestSetup(t *testing.T) { + tests := []struct { + input string + shouldErr bool + expectedLen int + expectedNameZone []string // contains only entry for now + }{ + // ok + {`federation { + prod prod.example.org + }`, false, 1, []string{"prod", "prod.example.org."}}, + + {`federation { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"prod", "prod.example.org."}}, + {`federation { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"staging", "staging.example.org."}}, + {`federation example.com { + staging staging.example.org + prod prod.example.org + }`, false, 2, []string{"staging", "staging.example.org."}}, + // errors + {`federation { + }`, true, 0, []string{}}, + {`federation { + staging + }`, true, 0, []string{}}, + } + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + fed, err := federationParse(c) + if test.shouldErr && err == nil { + t.Errorf("Test %v: Expected error but found nil", i) + continue + } else if !test.shouldErr && err != nil { + t.Errorf("Test %v: Expected no error but found error: %v", i, err) + continue + } + if test.shouldErr && err != nil { + continue + } + + if x := len(fed.f); x != test.expectedLen { + t.Errorf("Test %v: Expected map length of %d, got: %d", i, test.expectedLen, x) + } + if x, ok := fed.f[test.expectedNameZone[0]]; !ok { + t.Errorf("Test %v: Expected name for %s, got nothing", i, test.expectedNameZone[0]) + } else { + if x != test.expectedNameZone[1] { + t.Errorf("Test %v: Expected zone: %s, got %s", i, test.expectedNameZone[1], x) + } + } + } +} |