mw/federation: add federation back as separate mw for k8s (#929)

* mw/federaration

This PR add the federation back as a middleware to keep it more
contained from the main kubernetes code.

It also makes parseRequest less import and pushes this functionlity down
in the k.Entries. This minimizes (or tries to) the importance for the
qtype in the query. In the end the qtype checking should only happen
in ServeDNS - but for k8s this might proof difficult.

Numerous other cleanup in code and kubernetes tests.

* up test coverage
This commit is contained in:
Miek Gieben
2017-08-18 14:45:20 +01:00
committed by GitHub
parent cc4e4a0626
commit f96cf27193
25 changed files with 727 additions and 123 deletions

View File

@@ -0,0 +1,43 @@
# federation
The *federation* middleware enables
[federated](https://kubernetes.io/docs/tasks/federation/federation-service-discovery/) queries to be
resolved via the kubernetes middleware.
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
}
}
~~~

View File

@@ -0,0 +1,140 @@
/*
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 middleware, without it is a noop.
*/
package federation
import (
"strings"
"github.com/coredns/coredns/middleware"
"github.com/coredns/coredns/middleware/etcd/msg"
"github.com/coredns/coredns/middleware/pkg/dnsutil"
"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 middleware.Handler
Federations Func
}
// Func needs to be implemented by any middleware that implements
// federation. Right now this is only the kubernetes middleware.
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 middleware.Handle interface.
func (f *Federation) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
if f.Federations == nil {
return middleware.NextOrFailure(f.Name(), f.Next, ctx, w, r)
}
state := request.Request{W: w, Req: r}
zone := middleware.Zones(f.zones).Matches(state.Name())
if zone == "" {
return middleware.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 middleware.NextOrFailure(f.Name(), f.Next, ctx, w, r)
}
qname := r.Question[0].Name
r.Question[0].Name = without
state.Clear()
// Start the next middleware, but with a nowriter, capture the result, if NXDOMAIN
// perform federation, otherwise just write the result.
nw := NewNonWriter(w)
ret, err := middleware.NextOrFailure(f.Name(), f.Next, ctx, nw, r)
if !middleware.ClientWrite(ret) {
// something went wrong
return ret, err
}
if m := nw.Msg; m.Rcode != dns.RcodeNameError {
// If positive answer we need to substitute the orinal qname in question and answer.
r.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 {
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 middleware.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
// emtpy 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 := strings.Join(labels[:ll-2], ".") + "." + labels[ll-1] + "." + zone
return without, fed
}
return "", ""
}

View File

@@ -0,0 +1,81 @@
package federation
import (
"testing"
"github.com/coredns/coredns/middleware/kubernetes"
"github.com/coredns/coredns/middleware/pkg/dnsrecorder"
"github.com/coredns/coredns/middleware/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)
}
}

View File

@@ -0,0 +1,111 @@
package federation
import (
"github.com/coredns/coredns/middleware/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
}

View File

@@ -0,0 +1,22 @@
package federation
import (
"github.com/miekg/dns"
)
// NonWriter is a type of ResponseWriter that captures the message, but never writes to the client.
type NonWriter struct {
dns.ResponseWriter
Msg *dns.Msg
}
// NewNonWriter makes and returns a new NonWriter.
func NewNonWriter(w dns.ResponseWriter) *NonWriter { return &NonWriter{ResponseWriter: w} }
// WriteMsg records the message, but doesn't write it itself.
func (r *NonWriter) WriteMsg(res *dns.Msg) error {
r.Msg = res
return nil
}
func (r *NonWriter) Write(buf []byte) (int, error) { return len(buf), nil }

View File

@@ -0,0 +1,81 @@
package federation
import (
"fmt"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/middleware"
"github.com/coredns/coredns/middleware/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 middleware.Error("federation", err)
}
// Do this in OnStartup, so all middleware has been initialized.
c.OnStartup(func() error {
m := dnsserver.GetConfig(c).GetHandler("kubernetes")
if m == nil {
return nil
}
if x, ok := m.(kubernetes.Kubernetes); ok {
fed.Federations = x.Federations
}
return nil
})
dnsserver.GetConfig(c).AddMiddleware(func(next middleware.Handler) middleware.Handler {
fed.Next = next
return nil
})
return nil
}
func federationParse(c *caddy.Controller) (*Federation, error) {
fed := New()
for c.Next() {
// federation [zones..]
origins := make([]string, len(c.ServerBlockKeys))
copy(origins, c.ServerBlockKeys)
for c.NextBlock() {
x := c.Val()
switch c.Val() {
default:
args := c.RemainingArgs()
if len(args) != 1 {
return fed, fmt.Errorf("need two arguments for federation: %q", args)
}
fed.f[x] = dns.Fqdn(args[0])
}
}
for i := range origins {
origins[i] = middleware.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
}

View File

@@ -0,0 +1,60 @@
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
}{
{`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."}},
// 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)
}
}
}
}