knative.dev/pkg@v0.0.0-20260602142205-ac97e43f6622/test/spoof/spoof.go (about)

     1  /*
     2  Copyright 2019 The Knative Authors
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  // spoof contains logic to make polling HTTP requests against an endpoint with optional host spoofing.
    18  
    19  package spoof
    20  
    21  import (
    22  	"context"
    23  	"errors"
    24  	"fmt"
    25  	"io"
    26  	"net"
    27  	"net/http"
    28  	"net/url"
    29  	"time"
    30  
    31  	"k8s.io/apimachinery/pkg/util/wait"
    32  	"k8s.io/client-go/kubernetes"
    33  	"knative.dev/pkg/test/ingress"
    34  	"knative.dev/pkg/test/logging"
    35  
    36  	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    37  	"go.opentelemetry.io/otel"
    38  	"go.opentelemetry.io/otel/trace"
    39  )
    40  
    41  var tracer trace.Tracer
    42  
    43  func init() {
    44  	tracer = otel.GetTracerProvider().Tracer("knative.dev/pkg/test/spoof")
    45  }
    46  
    47  // Response is a stripped down subset of http.Response. The is primarily useful
    48  // for ResponseCheckers to inspect the response body without consuming it.
    49  // Notably, Body is a byte slice instead of an io.ReadCloser.
    50  type Response struct {
    51  	Status     string
    52  	StatusCode int
    53  	Header     http.Header
    54  	Body       []byte
    55  }
    56  
    57  func (r *Response) String() string {
    58  	return fmt.Sprintf("status: %d, body: %s, headers: %v", r.StatusCode, string(r.Body), r.Header)
    59  }
    60  
    61  // https://medium.com/stupid-gopher-tricks/ensuring-go-interface-satisfaction-at-compile-time-1ed158e8fa17
    62  var dialContext = (&net.Dialer{}).DialContext
    63  
    64  // ResponseChecker is used to determine when SpoofingClient.Poll is done polling.
    65  // This allows you to predicate wait.PollImmediate on the request's http.Response.
    66  //
    67  // See the apimachinery wait package:
    68  // https://github.com/kubernetes/apimachinery/blob/cf7ae2f57dabc02a3d215f15ca61ae1446f3be8f/pkg/util/wait/wait.go#L172
    69  type ResponseChecker func(resp *Response) (done bool, err error)
    70  
    71  // ErrorRetryChecker is used to determine if an error should be retried or not.
    72  // If an error should be retried, it should return true and the wrapped error to explain why to retry.
    73  type ErrorRetryChecker func(e error) (retry bool, err error)
    74  
    75  // ResponseRetryChecker is used to determine if a response should be retried or not.
    76  // If a response should be retried, it should return true and an error to explain why to retry.
    77  //
    78  // This is distinct from ResponseChecker in that it shall be used to retry responses,
    79  // where the HTTP request was technically successful (it returned something) but indicates
    80  // an error (e.g. the overload page of a loadbalancer).
    81  type ResponseRetryChecker func(resp *Response) (retry bool, err error)
    82  
    83  // SpoofingClient is a minimal HTTP client wrapper that spoofs the domain of requests
    84  // for non-resolvable domains.
    85  type SpoofingClient struct {
    86  	Client          *http.Client
    87  	RequestInterval time.Duration
    88  	RequestTimeout  time.Duration
    89  	Logf            logging.FormatLogger
    90  }
    91  
    92  // TransportOption allows callers to customize the http.Transport used by a SpoofingClient
    93  type TransportOption func(transport *http.Transport) *http.Transport
    94  
    95  // New returns a SpoofingClient that rewrites requests if the target domain is not `resolvable`.
    96  // It does this by looking up the ingress at construction time, so reusing a client will not
    97  // follow the ingress if it moves (or if there are multiple ingresses).
    98  //
    99  // If that's a problem, see test/request.go#WaitForEndpointState for oneshot spoofing.
   100  func New(
   101  	ctx context.Context,
   102  	kubeClientset kubernetes.Interface,
   103  	logf logging.FormatLogger,
   104  	domain string,
   105  	resolvable bool,
   106  	endpointOverride string,
   107  	requestInterval, requestTimeout time.Duration,
   108  	opts ...TransportOption,
   109  ) (*SpoofingClient, error) {
   110  	endpoint, mapper, err := ResolveEndpoint(ctx, kubeClientset, domain, resolvable, endpointOverride)
   111  	if err != nil {
   112  		return nil, fmt.Errorf("failed to get the cluster endpoint: %w", err)
   113  	}
   114  
   115  	// Spoof the hostname at the resolver level
   116  	logf("Spoofing %s -> %s", domain, endpoint)
   117  	transport := &http.Transport{
   118  		DialContext: func(ctx context.Context, network, addr string) (conn net.Conn, e error) {
   119  			_, port, err := net.SplitHostPort(addr)
   120  			if err != nil {
   121  				return nil, err
   122  			}
   123  			// The original hostname:port is spoofed by replacing the hostname by the value
   124  			// returned by ResolveEndpoint.
   125  			return dialContext(ctx, network, net.JoinHostPort(endpoint, mapper(port)))
   126  		},
   127  	}
   128  
   129  	for _, opt := range opts {
   130  		transport = opt(transport)
   131  	}
   132  
   133  	roundTripper := otelhttp.NewTransport(transport)
   134  
   135  	sc := &SpoofingClient{
   136  		Client:          &http.Client{Transport: roundTripper},
   137  		RequestInterval: requestInterval,
   138  		RequestTimeout:  requestTimeout,
   139  		Logf:            logf,
   140  	}
   141  	return sc, nil
   142  }
   143  
   144  // ResolveEndpoint resolves the endpoint address considering whether the domain is resolvable and taking into
   145  // account whether the user overrode the endpoint address externally
   146  func ResolveEndpoint(ctx context.Context, kubeClientset kubernetes.Interface, domain string, resolvable bool, endpointOverride string) (string, func(string) string, error) {
   147  	id := func(in string) string { return in }
   148  	// If the domain is resolvable, it can be used directly
   149  	if resolvable {
   150  		return domain, id, nil
   151  	}
   152  	// Otherwise, use the actual cluster endpoint
   153  	return ingress.GetIngressEndpoint(ctx, kubeClientset, endpointOverride)
   154  }
   155  
   156  // Do dispatches to the underlying http.Client.Do, spoofing domains as needed
   157  // and transforming the http.Response into a spoof.Response.
   158  // Each response is augmented with "X-Trace-Id" header that identifies the trace corresponding to the request.
   159  func (sc *SpoofingClient) Do(req *http.Request, errorRetryCheckers ...interface{}) (*Response, error) {
   160  	return sc.Poll(req, func(*Response) (bool, error) { return true, nil }, errorRetryCheckers...)
   161  }
   162  
   163  // Poll executes an http request until it satisfies the inState condition or, if there's an error,
   164  // none of the error retry checkers permit a retry.
   165  // If no retry checkers are specified `DefaultErrorRetryChecker` will be used.
   166  func (sc *SpoofingClient) Poll(req *http.Request, inState ResponseChecker, checkers ...interface{}) (*Response, error) {
   167  	if len(checkers) == 0 {
   168  		checkers = []interface{}{ErrorRetryChecker(DefaultErrorRetryChecker), ResponseRetryChecker(DefaultResponseRetryChecker)}
   169  	}
   170  
   171  	var resp *Response
   172  	err := wait.PollUntilContextTimeout(context.Background(), sc.RequestInterval, sc.RequestTimeout, true, func(ctx context.Context) (bool, error) {
   173  		// Starting span to capture zipkin trace.
   174  		traceContext, span := tracer.Start(req.Context(), "SpoofingClient-Trace")
   175  		defer span.End()
   176  		rawResp, err := sc.Client.Do(req.WithContext(traceContext))
   177  		if err != nil {
   178  			for _, checker := range checkers {
   179  				if ec, ok := checker.(ErrorRetryChecker); ok {
   180  					retry, newErr := ec(err)
   181  					if retry {
   182  						sc.Logf("Retrying %s: %v", req.URL.String(), newErr)
   183  						return false, nil
   184  					}
   185  				}
   186  			}
   187  			sc.Logf("NOT Retrying %s: %v", req.URL.String(), err)
   188  			return true, err
   189  		}
   190  		defer rawResp.Body.Close()
   191  
   192  		body, err := io.ReadAll(rawResp.Body)
   193  		if err != nil {
   194  			return true, err
   195  		}
   196  
   197  		resp = &Response{
   198  			Status:     rawResp.Status,
   199  			StatusCode: rawResp.StatusCode,
   200  			Header:     rawResp.Header,
   201  			Body:       body,
   202  		}
   203  
   204  		// This is distinct from inState in that it allows us to uniformly check for
   205  		// error responses to retry HTTP requests that have technically been successful,
   206  		// but haven't reached their destination (e.g. got a loadbalancer overload page).
   207  		for _, checker := range checkers {
   208  			if rc, ok := checker.(ResponseRetryChecker); ok {
   209  				retry, newErr := rc(resp)
   210  				if retry {
   211  					sc.Logf("Retrying %s: %v", req.URL.String(), newErr)
   212  					return false, nil
   213  				}
   214  			}
   215  		}
   216  
   217  		return inState(resp)
   218  	})
   219  	if err != nil {
   220  		return resp, fmt.Errorf("response: %s did not pass checks: %w", resp, err)
   221  	}
   222  	return resp, nil
   223  }
   224  
   225  // DefaultErrorRetryChecker implements the defaults for retrying on error.
   226  func DefaultErrorRetryChecker(err error) (bool, error) {
   227  	if isTCPTimeout(err) {
   228  		return true, fmt.Errorf("retrying for TCP timeout: %w", err)
   229  	}
   230  	// Retrying on DNS error, since we may be using sslip.io or nip.io in tests.
   231  	if isDNSError(err) {
   232  		return true, fmt.Errorf("retrying for DNS error: %w", err)
   233  	}
   234  	// Repeat the poll on `connection refused` errors, which are usually transient Istio errors.
   235  	if isConnectionRefused(err) {
   236  		return true, fmt.Errorf("retrying for connection refused: %w", err)
   237  	}
   238  	if isConnectionReset(err) {
   239  		return true, fmt.Errorf("retrying for connection reset: %w", err)
   240  	}
   241  	// Retry on connection/network errors.
   242  	if errors.Is(err, io.EOF) {
   243  		return true, fmt.Errorf("retrying for: %w", err)
   244  	}
   245  	// No route to host errors are in the same category as connection refused errors and
   246  	// are usually transient.
   247  	if isNoRouteToHostError(err) {
   248  		return true, fmt.Errorf("retrying for 'no route to host' error: %w", err)
   249  	}
   250  	return false, err
   251  }
   252  
   253  // DefaultResponseRetryChecker implements the defaults for retrying on response.
   254  func DefaultResponseRetryChecker(resp *Response) (bool, error) {
   255  	if isResponseDNSError(resp) {
   256  		return true, fmt.Errorf("retrying for DNS related failure response: %v", resp)
   257  	}
   258  	return false, nil
   259  }
   260  
   261  func (sc *SpoofingClient) WaitForEndpointState(
   262  	ctx context.Context,
   263  	url *url.URL,
   264  	inState ResponseChecker,
   265  	desc string,
   266  	opts ...RequestOption,
   267  ) (*Response, error) {
   268  	return sc.endpointState(
   269  		ctx,
   270  		url,
   271  		inState,
   272  		desc,
   273  		func(req *http.Request, check ResponseChecker) (*Response, error) { return sc.Poll(req, check) },
   274  		"WaitForEndpointState",
   275  		opts...)
   276  }
   277  
   278  func (sc *SpoofingClient) endpointState(
   279  	ctx context.Context,
   280  	url *url.URL,
   281  	inState ResponseChecker,
   282  	desc string,
   283  	f func(*http.Request, ResponseChecker) (*Response, error),
   284  	logName string,
   285  	opts ...RequestOption,
   286  ) (*Response, error) {
   287  	defer logging.GetEmitableSpan(ctx, logName+"/"+desc).End()
   288  
   289  	if url.Scheme == "" || url.Host == "" {
   290  		return nil, fmt.Errorf("invalid URL: %q", url.String())
   291  	}
   292  
   293  	req, err := http.NewRequest(http.MethodGet, url.String(), nil)
   294  	if err != nil {
   295  		return nil, err
   296  	}
   297  
   298  	for _, opt := range opts {
   299  		opt(req)
   300  	}
   301  
   302  	return f(req, inState)
   303  }
   304  
   305  func (sc *SpoofingClient) Check(req *http.Request, inState ResponseChecker, checkers ...interface{}) (*Response, error) {
   306  	resp, err := sc.Do(req, checkers...)
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  
   311  	ok, err := inState(resp)
   312  	if err != nil {
   313  		return resp, fmt.Errorf("response: %s did not pass checks: %w", resp, err)
   314  	}
   315  	if ok {
   316  		return resp, nil
   317  	}
   318  
   319  	return nil, err
   320  }
   321  
   322  func (sc *SpoofingClient) CheckEndpointState(
   323  	ctx context.Context,
   324  	url *url.URL,
   325  	inState ResponseChecker,
   326  	desc string,
   327  	opts ...RequestOption,
   328  ) (*Response, error) {
   329  	return sc.endpointState(
   330  		ctx,
   331  		url,
   332  		inState,
   333  		desc,
   334  		func(req *http.Request, check ResponseChecker) (*Response, error) { return sc.Check(req, check) },
   335  		"CheckEndpointState",
   336  		opts...)
   337  }