diff --git a/go.mod b/go.mod index 7190d44ea..498acdebf 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,6 @@ require ( github.com/goccy/go-yaml v1.18.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/linki/instrumented_http v0.3.0 github.com/linode/linodego v1.53.0 github.com/maxatome/go-testdeep v1.14.0 github.com/miekg/dns v1.1.67 diff --git a/go.sum b/go.sum index 3ab0aecf1..b7f9df376 100644 --- a/go.sum +++ b/go.sum @@ -676,8 +676,6 @@ github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/linki/instrumented_http v0.3.0 h1:dsN92+mXpfZtjJraartcQ99jnuw7fqsnPDjr85ma2dA= -github.com/linki/instrumented_http v0.3.0/go.mod h1:pjYbItoegfuVi2GUOMhEqzvm/SJKuEL3H0tc8QRLRFk= github.com/linode/linodego v1.53.0 h1:UWr7bUUVMtcfsuapC+6blm6+jJLPd7Tf9MZUpdOERnI= github.com/linode/linodego v1.53.0/go.mod h1:bI949fZaVchjWyKIA08hNyvAcV6BAS+PM2op3p7PAWA= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= diff --git a/pkg/http/http.go b/pkg/http/http.go new file mode 100644 index 000000000..b6069f1a5 --- /dev/null +++ b/pkg/http/http.go @@ -0,0 +1,97 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// ref: https://github.com/linki/instrumented_http/blob/master/client.go + +package http + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + requestDuration = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "request_duration_seconds", + Help: "The HTTP request latencies in seconds.", + Subsystem: "http", + ConstLabels: prometheus.Labels{"handler": "instrumented_http"}, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }, + []string{"scheme", "host", "path", "method", "status"}, + ) +) + +func init() { + prometheus.MustRegister(requestDuration) +} + +type CustomRoundTripper struct { + next http.RoundTripper +} + +// CancelRequest is a no-op to satisfy interfaces that require it. +// https://github.com/kubernetes/client-go/blob/34f52c14eaed7e50c845cc14e85e1c4c91e77470/transport/transport.go#L346 +func (r *CustomRoundTripper) CancelRequest(_ *http.Request) { +} + +func (r *CustomRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + resp, err := r.next.RoundTrip(req) + + status := "" + if resp != nil { + status = fmt.Sprintf("%d", resp.StatusCode) + } + + labels := prometheus.Labels{ + "scheme": req.URL.Scheme, + "host": req.URL.Host, + "path": pathProcessor(req.URL.Path), + "method": req.Method, + "status": status, + } + requestDuration.With(labels).Observe(time.Since(start).Seconds()) + return resp, err +} + +func NewInstrumentedClient(next *http.Client) *http.Client { + if next == nil { + next = http.DefaultClient + } + + next.Transport = NewInstrumentedTransport(next.Transport) + + return next +} + +func NewInstrumentedTransport(next http.RoundTripper) http.RoundTripper { + if next == nil { + next = http.DefaultTransport + } + + return &CustomRoundTripper{next: next} +} + +func pathProcessor(path string) string { + parts := strings.Split(path, "/") + return parts[len(parts)-1] +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go new file mode 100644 index 000000000..cd17f3873 --- /dev/null +++ b/pkg/http/http_test.go @@ -0,0 +1,81 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package http + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +type dummyTransport struct{} + +func (d *dummyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("dummy error") +} + +func TestNewInstrumentedTransport(t *testing.T) { + dt := &dummyTransport{} + rt := NewInstrumentedTransport(dt) + crt, ok := rt.(*CustomRoundTripper) + require.True(t, ok) + require.Equal(t, dt, crt.next) + + // Should default to http.DefaultTransport if nil + rt2 := NewInstrumentedTransport(nil) + crt2, ok := rt2.(*CustomRoundTripper) + require.True(t, ok) + require.Equal(t, http.DefaultTransport, crt2.next) +} + +func TestNewInstrumentedClient(t *testing.T) { + client := &http.Client{Transport: &dummyTransport{}} + result := NewInstrumentedClient(client) + require.Equal(t, client, result) + _, ok := result.Transport.(*CustomRoundTripper) + require.True(t, ok) + + // Should default to http.DefaultClient if nil + result2 := NewInstrumentedClient(nil) + require.Equal(t, http.DefaultClient, result2) + _, ok = result2.Transport.(*CustomRoundTripper) + require.True(t, ok) +} + +func TestPathProcessor(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/foo/bar", "bar"}, + {"/foo/", ""}, + {"/", ""}, + {"", ""}, + {"/foo/bar/baz", "baz"}, + {"foo/bar", "bar"}, + {"foo", "foo"}, + {"foo/", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + require.Equal(t, tt.expected, pathProcessor(tt.input)) + }) + } +} diff --git a/provider/aws/config.go b/provider/aws/config.go index 5908150e7..ecc53c904 100644 --- a/provider/aws/config.go +++ b/provider/aws/config.go @@ -20,16 +20,16 @@ import ( "context" "fmt" "net/http" - "strings" awsv2 "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/aws/aws-sdk-go-v2/config" stscredsv2 "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" - "github.com/linki/instrumented_http" "github.com/sirupsen/logrus" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) @@ -84,12 +84,7 @@ func newV2Config(awsConfig AWSSessionConfig) (awsv2.Config, error) { config.WithRetryer(func() awsv2.Retryer { return retry.AddWithMaxAttempts(retry.NewStandard(), awsConfig.APIRetries) }), - config.WithHTTPClient(instrumented_http.NewClient(&http.Client{}, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - })), + config.WithHTTPClient(extdnshttp.NewInstrumentedClient(&http.Client{})), config.WithSharedConfigProfile(awsConfig.Profile), } diff --git a/provider/google/google.go b/provider/google/google.go index 68217e37b..fd521fc0e 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -20,17 +20,17 @@ import ( "context" "fmt" "sort" - "strings" "time" "cloud.google.com/go/compute/metadata" - "github.com/linki/instrumented_http" log "github.com/sirupsen/logrus" "golang.org/x/oauth2/google" dns "google.golang.org/api/dns/v1" googleapi "google.golang.org/api/googleapi" "google.golang.org/api/option" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" @@ -131,12 +131,7 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter *endpoi return nil, err } - gcloud = instrumented_http.NewClient(gcloud, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - }) + gcloud = extdnshttp.NewInstrumentedClient(gcloud) dnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud)) if err != nil { diff --git a/provider/pihole/client.go b/provider/pihole/client.go index cccf85237..f133735d7 100644 --- a/provider/pihole/client.go +++ b/provider/pihole/client.go @@ -28,10 +28,11 @@ import ( "net/url" "strings" - "github.com/linki/instrumented_http" log "github.com/sirupsen/logrus" "golang.org/x/net/html" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) @@ -71,7 +72,8 @@ func newPiholeClient(cfg PiholeConfig) (piholeAPI, error) { }, }, } - cl := instrumented_http.NewClient(httpClient, &instrumented_http.Callbacks{}) + + cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClient{ cfg: cfg, diff --git a/provider/pihole/clientV6.go b/provider/pihole/clientV6.go index 0d220b03c..3168b497c 100644 --- a/provider/pihole/clientV6.go +++ b/provider/pihole/clientV6.go @@ -30,9 +30,10 @@ import ( "strconv" "strings" - "github.com/linki/instrumented_http" log "github.com/sirupsen/logrus" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) @@ -65,7 +66,7 @@ func newPiholeClientV6(cfg PiholeConfig) (piholeAPI, error) { }, } - cl := instrumented_http.NewClient(httpClient, &instrumented_http.Callbacks{}) + cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClientV6{ cfg: cfg, @@ -164,17 +165,17 @@ func (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endp DNSName, Target = recs[1], recs[0] switch rtype { case endpoint.RecordTypeA: - //PiHole return A and AAAA records. Filter to only keep the A records + // PiHole return A and AAAA records. Filter to only keep the A records if !isValidIPv4(Target) { continue } case endpoint.RecordTypeAAAA: - //PiHole return A and AAAA records. Filter to only keep the AAAA records + // PiHole return A and AAAA records. Filter to only keep the AAAA records if !isValidIPv6(Target) { continue } case endpoint.RecordTypeCNAME: - //PiHole return only CNAME records. + // PiHole return only CNAME records. // CNAME format is DNSName,target, ttl? DNSName, Target = recs[0], recs[1] if len(recs) == 3 { // TTL is present diff --git a/source/store.go b/source/store.go index 8d69a31bb..c77e89c3b 100644 --- a/source/store.go +++ b/source/store.go @@ -22,12 +22,11 @@ import ( "fmt" "net/http" "os" - "strings" + "sync" "time" "github.com/cloudfoundry-community/go-cfclient" - "github.com/linki/instrumented_http" openshift "github.com/openshift/client-go/route/clientset/versioned" log "github.com/sirupsen/logrus" istioclient "istio.io/client-go/pkg/clientset/versioned" @@ -38,6 +37,8 @@ import ( "k8s.io/client-go/tools/clientcmd" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) @@ -623,14 +624,11 @@ func instrumentedRESTConfig(kubeConfig, apiServerURL string, requestTimeout time if err != nil { return nil, err } + config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { - return instrumented_http.NewTransport(rt, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - }) + return extdnshttp.NewInstrumentedTransport(rt) } + config.Timeout = requestTimeout return config, nil }