diff --git a/rpadmin/admin.go b/rpadmin/admin.go index 88850f2..16d80db 100644 --- a/rpadmin/admin.go +++ b/rpadmin/admin.go @@ -21,6 +21,8 @@ import ( "math/rand" "net" "net/http" + "net/url" + "sort" "strconv" "strings" "sync" @@ -33,9 +35,14 @@ import ( commonnet "github.com/redpanda-data/common-go/net" ) -// ErrNoAdminAPILeader happen when there's no leader for the Admin API. +// ErrNoAdminAPILeader happens when there's no leader for the Admin API. var ErrNoAdminAPILeader = errors.New("no Admin API leader found") +// ErrNoSRVRecordsFound happens when we try to deduce Admin API URLs +// from Kubernetes SRV DNS records, but no records were returned by +// the DNS query. +var ErrNoSRVRecordsFound = errors.New("not SRV DNS records found") + // HTTPResponseError is the error response. type HTTPResponseError struct { Method string @@ -81,6 +88,7 @@ type GenericErrorBody struct { // AdminAPI is a client to interact with Redpanda's admin server. type AdminAPI struct { + urlsMutex sync.RWMutex urls []string brokerIDToUrlsMutex sync.Mutex brokerIDToUrls map[int]string @@ -190,20 +198,40 @@ func newAdminAPI(urls []string, auth Auth, tlsConfig *tls.Config, dialer DialCon a.retryClient.Transport = transport a.oneshotClient.Transport = transport + if err := a.initURLs(urls, tlsConfig, forCloud); err != nil { + return nil, err + } + + return a, nil +} + +const ( + schemeHTTP = "http" + schemeHTTPS = "https" +) + +func (a *AdminAPI) initURLs(urls []string, tlsConfig *tls.Config, forCloud bool) error { + a.urlsMutex.Lock() + defer a.urlsMutex.Unlock() + + if len(a.urls) != len(urls) { + a.urls = make([]string, len(urls)) + } + for i, u := range urls { scheme, host, err := commonnet.ParseHostMaybeScheme(u) if err != nil { - return nil, err + return err } switch scheme { - case "", "http": - scheme = "http" + case "", schemeHTTP: + scheme = schemeHTTP if tlsConfig != nil { - scheme = "https" + scheme = schemeHTTPS } - case "https": + case schemeHTTPS: default: - return nil, fmt.Errorf("unrecognized scheme %q in host %q", scheme, u) + return fmt.Errorf("unrecognized scheme %q in host %q", scheme, u) } full := fmt.Sprintf("%s://%s", scheme, host) if forCloud { @@ -212,7 +240,7 @@ func newAdminAPI(urls []string, auth Auth, tlsConfig *tls.Config, dialer DialCon a.urls[i] = full } - return a, nil + return nil } // SetAuth sets the auth in the client. @@ -225,10 +253,14 @@ func (a *AdminAPI) newAdminForSingleHost(host string) (*AdminAPI, error) { } func (a *AdminAPI) urlsWithPath(path string) []string { + a.urlsMutex.RLock() + defer a.urlsMutex.RUnlock() + urls := make([]string, len(a.urls)) for i := 0; i < len(a.urls); i++ { urls[i] = fmt.Sprintf("%s%s", a.urls[i], path) } + return urls } @@ -249,8 +281,13 @@ func (a *AdminAPI) mapBrokerIDsToURLs(ctx context.Context) { if err != nil { return err } + + a.urlsMutex.RLock() + url := aa.urls[0] + a.urlsMutex.RUnlock() + a.brokerIDToUrlsMutex.Lock() - a.brokerIDToUrls[nc.NodeID] = aa.urls[0] + a.brokerIDToUrls[nc.NodeID] = url a.brokerIDToUrlsMutex.Unlock() return nil }) @@ -280,9 +317,12 @@ func (a *AdminAPI) GetLeaderID(ctx context.Context) (*int, error) { func (a *AdminAPI) sendAny(ctx context.Context, method, path string, body, into any) error { // Shuffle the list of URLs rng := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec // old rpk code. + + a.urlsMutex.RLock() shuffled := make([]string, len(a.urls)) copy(shuffled, a.urls) rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + a.urlsMutex.RUnlock() // After a 503 or 504, wait a little for an election const unavailableBackoff = 1500 * time.Millisecond @@ -418,10 +458,12 @@ func (a *AdminAPI) getURLFromBrokerID(brokerID int) (string, bool) { func (a *AdminAPI) sendOne( ctx context.Context, method, path string, body, into any, retryable bool, ) error { + a.urlsMutex.RLock() if len(a.urls) != 1 { return fmt.Errorf("unable to issue a single-admin-endpoint request to %d admin endpoints", len(a.urls)) } url := a.urls[0] + path + a.urlsMutex.RUnlock() res, err := a.sendAndReceive(ctx, method, url, body, retryable) if err != nil { return err @@ -494,6 +536,7 @@ func (a *AdminAPI) sendAll(rootCtx context.Context, method, path string, body, i // for each of them in a go routine. func (a *AdminAPI) eachBroker(fn func(aa *AdminAPI) error) error { var grp multierror.Group + a.urlsMutex.RLock() for _, url := range a.urls { aURL := url grp.Go(func() error { @@ -504,6 +547,8 @@ func (a *AdminAPI) eachBroker(fn func(aa *AdminAPI) error) error { return fn(aa) }) } + a.urlsMutex.RUnlock() + return grp.Wait().ErrorOrNil() } @@ -630,3 +675,68 @@ func MaxRetries(r int) Opt { cl.MaxRetries = r }} } + +// AdminAddressesFromK8SDNS attempts to deduce admin API URLs +// based on Kubernetes DNS resolution. +// https://github.com/kubernetes/dns/blob/master/docs/specification.md +// Assume that Admin API URL configured is a Kubernetes Service URL. +// This Admin API URL is passed in as the function argument. +// Since it's a Kubernetes service, Kubernetes DNS creates a DNS SRV record +// for the admin port mapping. +// We can query the DNS record to get the target host names and ports. +// To check if a workload is running inside a kubernetes pod test for +// KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT env vars. +func AdminAddressesFromK8SDNS(adminAPIURL string) ([]string, error) { + adminURL, err := url.Parse(adminAPIURL) + if err != nil { + return nil, err + } + + _, records, err := net.LookupSRV("admin", "tcp", adminURL.Hostname()) + if err != nil { + return nil, err + } + + if len(records) == 0 { + return nil, ErrNoSRVRecordsFound + } + + // targets may be in the form + // redpanda-1.redpanda.redpanda.svc.cluster.local. + // take advantage of ordinals and order them accordingly + sort.Slice(records, func(i, j int) bool { + return records[i].Target < records[j].Target + }) + + urls := make([]string, 0, len(records)) + + proto := "http://" + if adminURL.Scheme == schemeHTTPS { + proto = "https://" + } + + for _, r := range records { + urls = append(urls, proto+r.Target+":"+strconv.Itoa(int(r.Port))) + } + + return urls, nil +} + +// UpdateAPIUrlsFromKubernetesDNS updates the client's internal URLs to admin addresses from Kubernetes DNS. +// See AdminAddressesFromK8SDNS. +func (a *AdminAPI) UpdateAPIUrlsFromKubernetesDNS() error { + a.urlsMutex.RLock() + if len(a.urls) == 0 { + return errors.New("at least one url is required for the admin api") + } + + baseURL := a.urls[0] + a.urlsMutex.RUnlock() + + urls, err := AdminAddressesFromK8SDNS(baseURL) + if err != nil { + return err + } + + return a.initURLs(urls, a.tlsConfig, a.forCloud) +} diff --git a/rpadmin/admin_test.go b/rpadmin/admin_test.go index cd6f608..020f21e 100644 --- a/rpadmin/admin_test.go +++ b/rpadmin/admin_test.go @@ -12,12 +12,16 @@ package rpadmin import ( "context" "fmt" + "net" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "testing" + "github.com/foxcpp/go-mockdns" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -222,3 +226,105 @@ func callsForNodeID(calls []testCall, nodeID int) []testCall { } return filtered } + +func TestAdminAddressesFromK8SDNS(t *testing.T) { + schemes := []string{"http", "https"} + + for _, scheme := range schemes { + t.Run(scheme, func(t *testing.T) { + adminAPIURL := scheme + "://" + "redpanda-api.cluster.local:19644" + + adminAPIHostURL, err := url.Parse(adminAPIURL) + require.NoError(t, err) + + srv, err := mockdns.NewServer(map[string]mockdns.Zone{ + "_admin._tcp." + adminAPIHostURL.Hostname() + ".": { + SRV: []net.SRV{ + { + Target: "rp-id123-0.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + { + Target: "rp-id123-1.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + { + Target: "rp-id123-2.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + }, + }, + }, false) + require.NoError(t, err) + + defer srv.Close() + + srv.PatchNet(net.DefaultResolver) + defer mockdns.UnpatchNet(net.DefaultResolver) + + brokerURLs, err := AdminAddressesFromK8SDNS(adminAPIURL) + assert.NoError(t, err) + require.Len(t, brokerURLs, 3) + assert.Equal(t, scheme+"://"+"rp-id123-0.rp-id123.redpanda.svc.cluster.local.:9644", brokerURLs[0]) + assert.Equal(t, scheme+"://"+"rp-id123-1.rp-id123.redpanda.svc.cluster.local.:9644", brokerURLs[1]) + assert.Equal(t, scheme+"://"+"rp-id123-2.rp-id123.redpanda.svc.cluster.local.:9644", brokerURLs[2]) + }) + } +} + +func TestUpdateAPIUrlsFromKubernetesDNS(t *testing.T) { + schemes := []string{"http", "https"} + + for _, scheme := range schemes { + t.Run(scheme, func(t *testing.T) { + adminAPIURL := scheme + "://" + "redpanda-api.cluster.local:19644" + + adminAPIHostURL, err := url.Parse(adminAPIURL) + require.NoError(t, err) + + srv, err := mockdns.NewServer(map[string]mockdns.Zone{ + "_admin._tcp." + adminAPIHostURL.Hostname() + ".": { + SRV: []net.SRV{ + { + Target: "rp-id123-0.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + { + Target: "rp-id123-1.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + { + Target: "rp-id123-2.rp-id123.redpanda.svc.cluster.local.", + Port: 9644, + Weight: 33, + }, + }, + }, + }, false) + require.NoError(t, err) + + defer srv.Close() + + srv.PatchNet(net.DefaultResolver) + defer mockdns.UnpatchNet(net.DefaultResolver) + + cl, err := NewClient([]string{adminAPIURL}, nil, nil, false) + require.NoError(t, err) + require.NotNil(t, cl) + assert.Len(t, cl.urls, 1) + + err = cl.UpdateAPIUrlsFromKubernetesDNS() + require.NoError(t, err) + require.NotNil(t, cl) + assert.Len(t, cl.urls, 3) + assert.Equal(t, scheme+"://"+"rp-id123-0.rp-id123.redpanda.svc.cluster.local.:9644", cl.urls[0]) + assert.Equal(t, scheme+"://"+"rp-id123-1.rp-id123.redpanda.svc.cluster.local.:9644", cl.urls[1]) + assert.Equal(t, scheme+"://"+"rp-id123-2.rp-id123.redpanda.svc.cluster.local.:9644", cl.urls[2]) + }) + } +} diff --git a/rpadmin/go.mod b/rpadmin/go.mod index 815d589..09cc7a8 100644 --- a/rpadmin/go.mod +++ b/rpadmin/go.mod @@ -3,6 +3,7 @@ module github.com/redpanda-data/common-go/rpadmin go 1.22.2 require ( + github.com/foxcpp/go-mockdns v1.1.0 github.com/hashicorp/go-multierror v1.1.1 github.com/redpanda-data/common-go/net v0.1.0 github.com/sethgrid/pester v1.2.0 @@ -13,7 +14,12 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/miekg/dns v1.1.57 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.18.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/tools v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/rpadmin/go.sum b/rpadmin/go.sum index f8c1421..82057df 100644 --- a/rpadmin/go.sum +++ b/rpadmin/go.sum @@ -1,10 +1,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redpanda-data/common-go/net v0.1.0 h1:JnJioRJuL961r1QXiJQ1tW9+yEaJfu8FpXnUmvQbwNM= @@ -13,12 +17,72 @@ github.com/sethgrid/pester v1.2.0 h1:adC9RS29rRUef3rIKWPOuP1Jm3/MmB6ke+OhE5giENI github.com/sethgrid/pester v1.2.0/go.mod h1:hEUINb4RqvDxtoCaU0BNT/HV4ig5kfgOasrf1xcvr0A= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=