[plugin/route53]: Support batch mode operation. (#2050)

* [plugin/route53]: Support batch mode operation.

Cache all Route53 records internally using `ListResourceRecordPagesWithContext`
and serve them from memory.

Bonus features:

  * Support additional r53 record types (`CNAME`, `SOA`, etc)
  * Support `upstream` option (#2099 filed to support argument optionality)

Signed-off-by: Dmitry Ilyevskiy <dmitry.ilyevskiy@getcruise.com>
Signed-off-by: Dmitry Ilyevskiy <ilyevsky@gmail.com>
This commit is contained in:
dilyevsky 2018-09-17 11:19:07 -07:00 committed by GitHub
parent 4ac06a342b
commit 153bd5f767
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 366 additions and 151 deletions

11
Gopkg.lock generated
View file

@ -330,6 +330,15 @@
packages = ["codec"]
revision = "f3cacc17c85ecb7f1b6a9e373ee85d1480919868"
[[projects]]
branch = "master"
name = "github.robot.car/cruise/coredns"
packages = [
"plugin/file",
"plugin/pkg/log"
]
revision = "18a77cd04557b810eba96a7239d39ee2d7a92157"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
@ -582,6 +591,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "851e08825d02558de62afea288af58e89bc67fe93a534c5e81f487e35328df22"
inputs-digest = "9c6559abfab43b450cf71b6cb95c0ba93fac74999a8cdc341ea725e82c29cebf"
solver-name = "gps-cdcl"
solver-version = 1

View file

@ -7,13 +7,15 @@
## Description
The route53 plugin is useful for serving zones from resource record sets in AWS route53. This plugin
only supports A and AAAA records. The route53 plugin can be used when coredns is deployed on AWS.
supports all Amazon Route 53 records (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html).
The route53 plugin can be used when coredns is deployed on AWS or elsewhere.
## Syntax
~~~ txt
route53 [ZONE:HOSTED_ZONE_ID...] {
[aws_access_key AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY]
upstream [ADDRESS...]
}
~~~
@ -23,18 +25,23 @@ route53 [ZONE:HOSTED_ZONE_ID...] {
to be used when query AWS (optional). If they are not provided, then coredns tries to access
AWS credentials the same way as AWS CLI, e.g., environmental variables, AWS credentials file,
instance profile credentials, etc.
* `upstream` [**ADDRESS**...] specifies upstream resolver(s) used for resolving services that point
to external hosts (eg. used to resolve CNAMEs). If no **ADDRESS** is given, CoreDNS will resolve
against itself. **ADDRESS** can be an IP, an IP:port or a path to a file structured like
resolv.conf (**NB**: Currently a bug (#2099) is preventing the use of self-resolver).
## Examples
Enable route53, with implicit aws credentials:
Enable route53 with implicit aws credentials and an upstream:
~~~ txt
. {
route53 example.org.:Z1Z2Z3Z4DZ5Z6Z7
upstream 10.0.0.1
}
~~~
Enable route53, with explicit aws credentials:
Enable route53 with explicit aws credentials:
~~~ txt
. {

View file

@ -1,12 +1,16 @@
// Package route53 implements a plugin that returns resource records
// from AWS route53
// from AWS route53.
package route53
import (
"context"
"net"
"fmt"
"sync"
"time"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/upstream"
"github.com/coredns/coredns/request"
"github.com/aws/aws-sdk-go/aws"
@ -15,96 +19,173 @@ import (
"github.com/miekg/dns"
)
// Route53 is a plugin that returns RR from AWS route53
// Route53 is a plugin that returns RR from AWS route53.
type Route53 struct {
Next plugin.Handler
zones []string
keys map[string]string
client route53iface.Route53API
zoneNames []string
client route53iface.Route53API
upstream *upstream.Upstream
zMu sync.RWMutex
zones map[string]*zone
}
// ServeDNS implements the plugin.Handler interface.
func (rr Route53) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
type zone struct {
id string
z *file.Zone
}
// New returns new *Route53.
func New(ctx context.Context, c route53iface.Route53API, keys map[string]string, up *upstream.Upstream) (*Route53, error) {
zones := make(map[string]*zone, len(keys))
zoneNames := make([]string, 0, len(keys))
for dns, id := range keys {
_, err := c.ListHostedZonesByNameWithContext(ctx, &route53.ListHostedZonesByNameInput{
DNSName: aws.String(dns),
HostedZoneId: aws.String(id),
})
if err != nil {
return nil, err
}
zones[dns] = &zone{id: id, z: file.NewZone(dns, "")}
zoneNames = append(zoneNames, dns)
}
return &Route53{
client: c,
zoneNames: zoneNames,
zones: zones,
upstream: up,
}, nil
}
// Run executes first update, spins up an update forever-loop.
// Returns error if first update fails.
func (h *Route53) Run(ctx context.Context) error {
if err := h.updateZones(ctx); err != nil {
return err
}
go func() {
for {
select {
case <-ctx.Done():
log.Infof("Breaking out of Route53 update loop: %v", ctx.Err())
return
case <-time.After(1 * time.Minute):
if err := h.updateZones(ctx); err != nil && ctx.Err() == nil /* Don't log error if ctx expired. */ {
log.Errorf("Failed to update zones: %v", err)
}
}
}
}()
return nil
}
// ServeDNS implements the plugin.Handler.ServeDNS.
func (h *Route53) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
state := request.Request{W: w, Req: r}
qname := state.Name()
zone := plugin.Zones(rr.zones).Matches(qname)
if zone == "" {
return plugin.NextOrFailure(rr.Name(), rr.Next, ctx, w, r)
zName := plugin.Zones(h.zoneNames).Matches(qname)
if zName == "" {
return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r)
}
output, err := rr.client.ListResourceRecordSets(&route53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(rr.keys[zone]),
StartRecordName: aws.String(qname),
StartRecordType: aws.String(state.Type()),
MaxItems: aws.String("1"),
})
if err != nil {
return dns.RcodeServerFailure, err
}
answers := []dns.RR{}
switch state.QType() {
case dns.TypeA:
answers = a(qname, output.ResourceRecordSets)
case dns.TypeAAAA:
answers = aaaa(qname, output.ResourceRecordSets)
case dns.TypePTR:
answers = ptr(qname, output.ResourceRecordSets)
}
if len(answers) == 0 {
return plugin.NextOrFailure(rr.Name(), rr.Next, ctx, w, r)
z, ok := h.zones[zName]
if !ok || z == nil {
return dns.RcodeServerFailure, nil
}
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative, m.RecursionAvailable = true, true
m.Answer = answers
var result file.Result
h.zMu.RLock()
m.Answer, m.Ns, m.Extra, result = z.z.Lookup(state, qname)
h.zMu.RUnlock()
switch result {
case file.Success:
case file.NoData:
case file.NameError:
m.Rcode = dns.RcodeNameError
case file.Delegation:
m.Authoritative = false
case file.ServerFailure:
return dns.RcodeServerFailure, nil
}
w.WriteMsg(m)
return dns.RcodeSuccess, nil
}
func a(zone string, rrss []*route53.ResourceRecordSet) []dns.RR {
answers := []dns.RR{}
for _, rrs := range rrss {
for _, rr := range rrs.ResourceRecords {
r := new(dns.A)
r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))}
r.A = net.ParseIP(aws.StringValue(rr.Value)).To4()
answers = append(answers, r)
func updateZoneFromRRS(rrs *route53.ResourceRecordSet, z *file.Zone) error {
for _, rr := range rrs.ResourceRecords {
// Assemble RFC 1035 conforming record to pass into dns scanner.
rfc1035 := fmt.Sprintf("%s %d IN %s %s", aws.StringValue(rrs.Name), aws.Int64Value(rrs.TTL), aws.StringValue(rrs.Type), aws.StringValue(rr.Value))
r, err := dns.NewRR(rfc1035)
if err != nil {
return fmt.Errorf("failed to parse resource record: %v", err)
}
z.Insert(r)
}
return answers
return nil
}
func aaaa(zone string, rrss []*route53.ResourceRecordSet) []dns.RR {
answers := []dns.RR{}
for _, rrs := range rrss {
for _, rr := range rrs.ResourceRecords {
r := new(dns.AAAA)
r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))}
r.AAAA = net.ParseIP(aws.StringValue(rr.Value)).To16()
answers = append(answers, r)
// updateZones re-queries resource record sets for each zone and updates the
// zone object.
// Returns error if any zones error'ed out, but waits for other zones to
// complete first.
func (h *Route53) updateZones(ctx context.Context) error {
errc := make(chan error)
defer close(errc)
for zName, z := range h.zones {
go func(zName string) {
var err error
defer func() {
errc <- err
}()
newZ := file.NewZone(zName, "")
newZ.Upstream = *h.upstream
in := &route53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(z.id),
}
err = h.client.ListResourceRecordSetsPagesWithContext(ctx, in,
func(out *route53.ListResourceRecordSetsOutput, last bool) bool {
for _, rrs := range out.ResourceRecordSets {
if err := updateZoneFromRRS(rrs, newZ); err != nil {
// Maybe unsupported record type. Log and carry on.
log.Warningf("Failed to process resource record set: %v", err)
}
}
return true
})
if err != nil {
err = fmt.Errorf("failed to list resource records for %v:%v from route53: %v", zName, z.id, err)
return
}
h.zMu.Lock()
z.z = newZ
h.zMu.Unlock()
}(zName)
}
// Collect errors (if any). This will also sync on all zones updates
// completion.
var errs []string
for i := 0; i < len(h.zones); i++ {
err := <-errc
if err != nil {
errs = append(errs, err.Error())
}
}
return answers
}
func ptr(zone string, rrss []*route53.ResourceRecordSet) []dns.RR {
answers := []dns.RR{}
for _, rrs := range rrss {
for _, rr := range rrs.ResourceRecords {
r := new(dns.PTR)
r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))}
r.Ptr = aws.StringValue(rr.Value)
answers = append(answers, r)
}
if len(errs) != 0 {
return fmt.Errorf("errors updating zones: %v", errs)
}
return answers
return nil
}
// Name implements the Handler interface.
func (rr Route53) Name() string { return "route53" }
// Name implements plugin.Handler.Name.
func (h *Route53) Name() string { return "route53" }

View file

@ -2,84 +2,163 @@ package route53
import (
"context"
"errors"
"reflect"
"testing"
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/coredns/coredns/plugin/pkg/upstream"
"github.com/coredns/coredns/plugin/test"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/route53"
"github.com/aws/aws-sdk-go/service/route53/route53iface"
"github.com/miekg/dns"
)
type mockedRoute53 struct {
type fakeRoute53 struct {
route53iface.Route53API
}
func (mockedRoute53) ListResourceRecordSets(input *route53.ListResourceRecordSetsInput) (*route53.ListResourceRecordSetsOutput, error) {
var value string
switch aws.StringValue(input.StartRecordType) {
case "A":
value = "10.2.3.4"
case "AAAA":
value = "2001:db8:85a3::8a2e:370:7334"
case "PTR":
value = "ptr.example.org"
func (fakeRoute53) ListHostedZonesByNameWithContext(_ aws.Context, input *route53.ListHostedZonesByNameInput, _ ...request.Option) (*route53.ListHostedZonesByNameOutput, error) {
return nil, nil
}
func (fakeRoute53) ListResourceRecordSetsPagesWithContext(_ aws.Context, in *route53.ListResourceRecordSetsInput, fn func(*route53.ListResourceRecordSetsOutput, bool) bool, _ ...request.Option) error {
if aws.StringValue(in.HostedZoneId) == "0987654321" {
return errors.New("bad. zone is bad")
}
return &route53.ListResourceRecordSetsOutput{
ResourceRecordSets: []*route53.ResourceRecordSet{
{
ResourceRecords: []*route53.ResourceRecord{
{
Value: aws.String(value),
},
var rrs []*route53.ResourceRecordSet
for _, r := range []struct {
rType, name, value string
}{
{"A", "example.org.", "1.2.3.4"},
{"AAAA", "example.org.", "2001:db8:85a3::8a2e:370:7334"},
{"CNAME", "sample.example.org.", "example.org"},
{"PTR", "example.org.", "ptr.example.org."},
{"SOA", "org.", "ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"},
{"NS", "com.", "ns-1536.awsdns-00.co.uk."},
// Unsupported type should be ignored.
{"YOLO", "swag.", "foobar"},
} {
rrs = append(rrs, &route53.ResourceRecordSet{Type: aws.String(r.rType),
Name: aws.String(r.name),
ResourceRecords: []*route53.ResourceRecord{
{
Value: aws.String(r.value),
},
},
},
}, nil
TTL: aws.Int64(300),
})
}
if ok := fn(&route53.ListResourceRecordSetsOutput{
ResourceRecordSets: rrs,
}, true); !ok {
return errors.New("paging function return false")
}
return nil
}
func TestRoute53(t *testing.T) {
r := Route53{
zones: []string{"example.org."},
keys: map[string]string{"example.org.": "1234567890"},
client: mockedRoute53{},
ctx := context.Background()
r, err := New(ctx, fakeRoute53{}, map[string]string{"bad.": "0987654321"}, &upstream.Upstream{})
if err != nil {
t.Fatalf("Failed to create Route53: %v", err)
}
if err = r.Run(ctx); err == nil {
t.Fatalf("Expected errors for zone bad.")
}
r, err = New(ctx, fakeRoute53{}, map[string]string{"org.": "1234567890"}, &upstream.Upstream{})
if err != nil {
t.Fatalf("Failed to create Route53: %v", err)
}
r.Next = test.ErrorHandler()
err = r.Run(ctx)
if err != nil {
t.Fatalf("Failed to initialize Route53: %v", err)
}
tests := []struct {
qname string
qtype uint16
expectedCode int
expectedReply []string // ownernames for the records in the additional section.
expectedErr error
qname string
qtype uint16
expectedCode int
wantAnswer []string // ownernames for the records in the additional section.
wantNS []string
expectedErr error
}{
// 0. example.org A found - success.
{
qname: "example.org",
qtype: dns.TypeA,
expectedCode: dns.RcodeSuccess,
expectedReply: []string{"10.2.3.4"},
expectedErr: nil,
qname: "example.org",
qtype: dns.TypeA,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{"example.org. 300 IN A 1.2.3.4"},
},
// 1. example.org AAAA found - success.
{
qname: "example.org",
qtype: dns.TypeAAAA,
expectedCode: dns.RcodeSuccess,
expectedReply: []string{"2001:db8:85a3::8a2e:370:7334"},
expectedErr: nil,
qname: "example.org",
qtype: dns.TypeAAAA,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{"example.org. 300 IN AAAA 2001:db8:85a3::8a2e:370:7334"},
},
// 2. exampled.org PTR found - success.
{
qname: "example.org",
qtype: dns.TypePTR,
expectedCode: dns.RcodeSuccess,
expectedReply: []string{"ptr.example.org"},
expectedErr: nil,
qname: "example.org",
qtype: dns.TypePTR,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{"example.org. 300 IN PTR ptr.example.org."},
},
// 3. sample.example.org points to example.org CNAME.
// Query must return both CNAME and A recs.
{
qname: "sample.example.org",
qtype: dns.TypeA,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{
"sample.example.org. 300 IN CNAME example.org.",
"example.org. 300 IN A 1.2.3.4",
},
},
// 4. Explicit CNAME query for sample.example.org.
// Query must return just CNAME.
{
qname: "sample.example.org",
qtype: dns.TypeCNAME,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{"sample.example.org. 300 IN CNAME example.org."},
},
// 5. Explicit SOA query for example.org.
{
qname: "example.org",
qtype: dns.TypeSOA,
expectedCode: dns.RcodeSuccess,
wantAnswer: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"},
},
// 6. Explicit SOA query for example.org.
{
qname: "example.org",
qtype: dns.TypeNS,
expectedCode: dns.RcodeSuccess,
wantNS: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"},
},
// 7. Zone not configured.
{
qname: "badexample.com",
qtype: dns.TypeA,
expectedCode: dns.RcodeServerFailure,
},
// 8. No record found. Return SOA record.
{
qname: "bad.org",
qtype: dns.TypeA,
expectedCode: dns.RcodeSuccess,
wantNS: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"},
},
}
ctx := context.TODO()
for i, tc := range tests {
for ti, tc := range tests {
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype)
@ -87,24 +166,32 @@ func TestRoute53(t *testing.T) {
code, err := r.ServeDNS(ctx, rec, req)
if err != tc.expectedErr {
t.Errorf("Test %d: Expected error %v, but got %v", i, tc.expectedErr, err)
t.Fatalf("Test %d: Expected error %v, but got %v", ti, tc.expectedErr, err)
}
if code != int(tc.expectedCode) {
t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code)
t.Fatalf("Test %d: Expected status code %s, but got %s", ti, dns.RcodeToString[tc.expectedCode], dns.RcodeToString[code])
}
if len(tc.expectedReply) != 0 {
for i, expected := range tc.expectedReply {
var actual string
switch tc.qtype {
case dns.TypeA:
actual = rec.Msg.Answer[i].(*dns.A).A.String()
case dns.TypeAAAA:
actual = rec.Msg.Answer[i].(*dns.AAAA).AAAA.String()
case dns.TypePTR:
actual = rec.Msg.Answer[i].(*dns.PTR).Ptr
if len(tc.wantAnswer) != len(rec.Msg.Answer) {
t.Errorf("Test %d: Unexpected number of Answers. Want: %d, got: %d", ti, len(tc.wantAnswer), len(rec.Msg.Answer))
} else {
for i, gotAnswer := range rec.Msg.Answer {
if gotAnswer.String() != tc.wantAnswer[i] {
t.Errorf("Test %d: Unexpected answer.\nWant:\n\t%s\nGot:\n\t%s", ti, tc.wantAnswer[i], gotAnswer)
}
if actual != expected {
t.Errorf("Test %d: Expected answer %s, but got %s", i, expected, actual)
}
}
if len(tc.wantNS) != len(rec.Msg.Ns) {
t.Errorf("Test %d: Unexpected NS number. Want: %d, got: %d", ti, len(tc.wantNS), len(rec.Msg.Ns))
} else {
for i, ns := range rec.Msg.Ns {
got, ok := ns.(*dns.SOA)
if !ok {
t.Errorf("Test %d: Unexpected NS type. Want: SOA, got: %v", ti, reflect.TypeOf(got))
}
if got.String() != tc.wantNS[i] {
t.Errorf("Test %d: Unexpected NS. Want: %v, got: %v", ti, tc.wantNS[i], got)
}
}
}

View file

@ -1,10 +1,13 @@
package route53
import (
"context"
"strings"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/plugin"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/coredns/coredns/plugin/pkg/upstream"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
@ -14,6 +17,8 @@ import (
"github.com/mholt/caddy"
)
var log = clog.NewWithPlugin("route53")
func init() {
caddy.RegisterPlugin("route53", caddy.Plugin{
ServerType: "dns",
@ -30,7 +35,8 @@ func init() {
func setup(c *caddy.Controller, f func(*credentials.Credentials) route53iface.Route53API) error {
keys := map[string]string{}
var credential *credentials.Credentials
credential := credentials.NewEnvCredentials()
up, _ := upstream.New(nil)
for c.Next() {
args := c.RemainingArgs()
@ -57,32 +63,35 @@ func setup(c *caddy.Controller, f func(*credentials.Credentials) route53iface.Ro
return c.Errf("invalid access key '%v'", v)
}
credential = credentials.NewStaticCredentials(v[0], v[1], "")
case "upstream":
args := c.RemainingArgs()
// TODO(dilyevsky): There is a bug that causes coredns to crash
// when no upstream endpoint is provided.
if len(args) == 0 {
return c.Errf("local upstream not supported. please provide upstream endpoint")
}
var err error
up, err = upstream.New(args)
if err != nil {
return c.Errf("invalid upstream: %v", err)
}
default:
return c.Errf("unknown property '%s'", c.Val())
}
}
}
client := f(credential)
zones := []string{}
for zone, v := range keys {
// Make sure enough credentials is needed
if _, err := client.ListResourceRecordSets(&route53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(v),
MaxItems: aws.String("1"),
}); err != nil {
return c.Errf("aws error: '%s'", err)
}
zones = append(zones, zone)
ctx := context.Background()
h, err := New(ctx, client, keys, &up)
if err != nil {
return c.Errf("failed to create Route53 plugin: %v", err)
}
if err := h.Run(ctx); err != nil {
return c.Errf("failed to initialize Route53 plugin: %v", err)
}
dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
return Route53{
Next: next,
keys: keys,
zones: zones,
client: client,
}
h.Next = next
return h
})
return nil

View file

@ -10,7 +10,7 @@ import (
func TestSetupRoute53(t *testing.T) {
f := func(credential *credentials.Credentials) route53iface.Route53API {
return mockedRoute53{}
return fakeRoute53{}
}
c := caddy.NewTestController("dns", `route53`)
@ -34,4 +34,26 @@ func TestSetupRoute53(t *testing.T) {
if err := setup(c, f); err == nil {
t.Fatalf("Expected errors, but got: %v", err)
}
c = caddy.NewTestController("dns", `route53 example.org:12345678 {
upstream
}`)
if err := setup(c, f); err == nil {
t.Fatalf("Expected errors, but got: %v", err)
}
c = caddy.NewTestController("dns", `route53 example.org:12345678 {
wat
}`)
if err := setup(c, f); err == nil {
t.Fatalf("Expected errors, but got: %v", err)
}
c = caddy.NewTestController("dns", `route53 example.org:12345678 {
aws_access_key ACCESS_KEY_ID SEKRIT_ACCESS_KEY
upstream 1.2.3.4
}`)
if err := setup(c, f); err != nil {
t.Fatalf("Unexpected errors: %v", err)
}
}